Bimodal Market Profile with Delta and Memory in MQL5
The market has long since outgrown the need for “yet another Market Profile” with a different color scheme — what it needs is a working tool that preserves Steidlmayer’s logic but stops misleading traders in modern trading regimes.
This article is intended for MQL5 developers, algorithmic traders, and advanced manual traders. We use tick data for exchange-traded instruments (CopyTicksRange with aggressor flags) and a bar-based fallback for Forex to build a solution suitable for integration into trading logic. We identify three practical blind spots in the classic MP:
- a monolithic VA and a single POC in bimodal sessions,
- a lack of information about the aggressor at specific levels,
- session isolation and lack of memory.
For each problem, we provide a formalized solution that can be implemented in MQL5. The final package includes a reference profile (pairwise VA), a bimodality detector with a dead zone and a secondary POC, a Delta Profile with absorption signals, and Composite Memory with exponential decay — all in a modular architecture designed for performance.
Chicago’s Legacy: What Exactly Did Peter Steidlmayer Invent, and Why Did It Work?
To understand which improvements really make sense, we need to take an honest look at our starting point. The Market Profile concept arose from a simple observation: if you treat a trading day as a set of half-hour intervals (known as TPOs — Time Price Opportunities) and lay out prices along the horizontal axis, the vertical histogram shows how much time price spent at each level. Steidlmayer interpreted this picture as a visualization of the auction process: levels with a high concentration of TPO are zones where buyers and sellers reached agreement on a fair price; levels with a low concentration are zones where there was no agreement and price moved through them quickly.
This simple idea gave rise to an entire methodology. The level with the highest concentration of activity was called the Point of Control (POC) — a control point, a magnet for price, and the level to which the market tends to return. The area around the POC, where 70% of all activity is concentrated, was called the Value Area, with boundaries at VAH (Value Area High) and VAL (Value Area Low). Choosing 70% corresponds to an interval of approximately one standard deviation in a normal distribution. This reflects Steidlmayer's assumption regarding the Gaussian nature of price formation in a state of equilibrium. Levels with single TPOs — Single Prints — were marked as rejection zones that the price moved through too quickly for any consensus to form there.
All of this worked back when the market was largely driven by people. By looking at yesterday’s profile, the floor trader received concentrated information: where the fair price was (POC), where fluctuations were considered normal (VA), and where the market had rejected values (extremes and Single Prints). It was literally a mapped-out auction, and it helped with decision-making.
But — and this is where the problems begin — this mapping works only in one specific case. In the case of a unimodal distribution. Steidlmayer implicitly assumed that, during a single session, the market forms a single consensus regarding the fair price, that there is only one POC, and that the VA is a single, continuous zone. That aggression at a level and the time spent at that level are one and the same. In 1982, on CBOT corn futures, that was a reasonable assumption; in 2026, EURUSD may go through several trading regimes within a single session. Major players often accumulate a position in one zone and unload it in another, and buyer aggression and seller aggression at the same level are not the same thing. Under these circumstances, the original assumptions begin to lead to distortions.
The Anatomy of a Classic Indicator: POC, Value Area, Single Prints — An Analysis of the Logic
Please note the PROFILE_HYBRID mode — this is my default recommendation. Pure TPO (weight of 1 per bar) ignores volume data, while pure Volume Profile suffers from outliers on large bars. Hybrid mode uses logarithmic weighting — bars with higher volume are given greater weight, but do not dominate the overall picture. This is the same technique used to smooth heavy-tailed distributions in statistics.
Finding the Value Area is where hand-rolled implementations most often break down. The naive algorithm simply expands the range around the POC by one level at a time, choosing the side with greater activity. That's wrong. Steidlmayer's original algorithm uses pairwise expansion: at each step, the two levels above and the two levels below are compared, and the expansion proceeds toward the pair with the higher total activity. This produces a more stable boundary and is less sensitive to isolated spikes in activity:
//+------------------------------------------------------------------+ //| Build session profile | //+------------------------------------------------------------------+ void BuildSessionProfile(SessionProfile &prof, const double &high[], const double &low[], const double &close[], const long &tick_volume[], const datetime &time[], const int session_index, const bool draw_visuals) { double s_high = -DBL_MAX, s_low = DBL_MAX; for(int b = prof.end_bar; b <= prof.start_bar; b++) { if(high[b] > s_high) s_high = high[b]; if(low[b] < s_low) s_low = low[b]; } prof.high = s_high; prof.low = s_low; if(s_high <= s_low) return; s_high = MathCeil(s_high / g_tick_size) * g_tick_size; s_low = MathFloor(s_low / g_tick_size) * g_tick_size; int levels = (int)MathRound((s_high - s_low) / g_tick_size) + 1; if(levels > InpMaxPriceLevels) levels = InpMaxPriceLevels; if(levels < 2) return; double activity[], prices[]; ArrayResize(activity, levels); ArrayResize(prices, levels); ArrayInitialize(activity, 0.0); for(int i = 0; i < levels; i++) prices[i] = s_low + i * g_tick_size; double total = 0; for(int b = prof.end_bar; b <= prof.start_bar; b++) { double weight = 1.0; if(InpProfileMode == PROFILE_VOLUME) weight = (double)tick_volume[b]; else if(InpProfileMode == PROFILE_HYBRID) weight = 1.0 + MathLog(1.0 + (double)tick_volume[b]); int lo_idx = (int)MathMax(0, MathRound((low[b] - s_low) / g_tick_size)); int hi_idx = (int)MathMin(levels - 1, MathRound((high[b] - s_low) / g_tick_size)); for(int i = lo_idx; i <= hi_idx; i++) { activity[i] += weight; total += weight; } } prof.total_activity = total; if(total <= 0) return; //--- Primary POC int poc_idx = 0; double poc_val = activity[0]; for(int i = 1; i < levels; i++) if(activity[i] > poc_val) { poc_val = activity[i]; poc_idx = i; } prof.poc_price = prices[poc_idx]; //--- Value Area (pairwise expansion) double target = total * (InpValueAreaPercent / 100.0); double va_sum = activity[poc_idx]; int va_hi = poc_idx, va_lo = poc_idx; while(va_sum < target && (va_hi < levels - 1 || va_lo > 0)) { double up_pair = 0, dn_pair = 0; if(va_hi + 1 < levels) up_pair += activity[va_hi + 1]; if(va_hi + 2 < levels) up_pair += activity[va_hi + 2]; if(va_lo - 1 >= 0) dn_pair += activity[va_lo - 1]; if(va_lo - 2 >= 0) dn_pair += activity[va_lo - 2]; if(up_pair >= dn_pair && va_hi < levels - 1) { va_hi++; va_sum += activity[va_hi]; if(va_hi < levels - 1 && va_sum < target) { va_hi++; va_sum += activity[va_hi]; } } else if(va_lo > 0) { va_lo--; va_sum += activity[va_lo]; if(va_lo > 0 && va_sum < target) { va_lo--; va_sum += activity[va_lo]; } } else break; } prof.vah_price = prices[va_hi]; prof.val_price = prices[va_lo]; //=== IDEA #1: Bimodal Detection === prof.is_bimodal = false; prof.secondary_poc = 0; prof.dead_zone_top = 0; prof.dead_zone_bot = 0; prof.vah2_price = 0; prof.val2_price = 0; if(InpEnableBimodal) DetectBimodal(prof, prices, activity, levels, poc_idx, poc_val, total); //=== IDEA #2: Delta Profile — compute only, render later === prof.delta_total = 0; prof.delta_at_poc = 0; prof.absorption_poc = false; double delta_arr[]; bool have_delta = false; if(InpEnableDelta && draw_visuals) { ComputeDeltaProfile(prof, prices, levels, delta_arr, tick_volume, time); if(ArraySize(delta_arr) == levels) { have_delta = true; for(int i = 0; i < levels; i++) prof.delta_total += delta_arr[i]; prof.delta_at_poc = delta_arr[poc_idx]; double delta_magnitude = MathAbs(prof.delta_at_poc); double activity_here = activity[poc_idx]; if(activity_here > 0 && delta_magnitude / activity_here > InpAbsorptionThresh) prof.absorption_poc = true; } } //--- Standard visuals if(draw_visuals) { DrawPremiumHistogram(prof, prices, activity, levels, poc_val, va_hi, va_lo, time, session_index); if(have_delta) ApplyDeltaTint(prof, prices, delta_arr, levels, session_index); if(prof.is_bimodal) DrawBimodalFeatures(prof, session_index); if(InpShowPOCLine) DrawGlowLine(prof, prof.poc_price, "POC", g_palette.poc_color, g_palette.poc_glow, STYLE_SOLID, 2, session_index, true); if(InpShowVALines) { DrawGlowLine(prof, prof.vah_price, "VAH", g_palette.va_accent, g_palette.va_color, STYLE_DASH, 1, session_index, false); DrawGlowLine(prof, prof.val_price, "VAL", g_palette.va_accent, g_palette.va_color, STYLE_DASH, 1, session_index, false); } if(InpShowSinglePrints) DrawSinglePrints(prof, prices, activity, levels, time, session_index); if(session_index == 0) { if(InpShowPOCRays) DrawRay(prof, prof.poc_price, "POC_RAY", g_palette.poc_color, time); if(InpShowVARays) { DrawRay(prof, prof.vah_price, "VAH_RAY", g_palette.va_accent, time); DrawRay(prof, prof.val_price, "VAL_RAY", g_palette.va_color, time); } } if(InpShowProfileLabels) DrawPremiumLabels(prof, session_index); } }
Problem #1: A monolithic Value Area is misleading when the distribution is bimodal
Let's consider a typical scenario. On EURUSD, earlier in the day price traded in the 1.0820–1.0845 range, where it spent four hours during the European session. Then a significant news release came out, the market moved lower, and formed a new balance range of 1.0780–1.0800, where it spent another four hours of the U.S. session. By the end of the day, the profile shows two distinct clusters of activity with an empty zone between them in the 1.0805–1.0815 area.
What does the classic Market Profile do in this situation? It finds the absolute POC — most likely somewhere in one of the clusters, say, 1.0832. It then constructs the Value Area as a single 70% zone, expanding its boundaries until it encompasses 70% of all activity. Since there is an empty zone between the clusters, the expansion algorithm still passes through it, and the final VA ends up ranging from approximately 1.0790 to 1.0840 — that is, it is spread across a dead zone where there was no significant activity.
A trader looking at this kind of VA receives misleading information. The middle of the 1.0805–1.0815 range is highlighted as part of the Value Area, although in reality this zone is where price fell through during the breakdown and moved through quickly. These are structurally different concepts, and Market Profile, in its classical form, does not distinguish between them.
The solution is bimodality detection and profile separation. The algorithm I implemented works as follows. First, all local activity maxima that satisfy the following two conditions are identified: the peak height is at least InpSecondaryPOCMinPct percent of the primary POC (35% by default), and the distance between peaks is at least InpMinPeakDistance levels. Next, for the two most significant peaks, the depth of the trough between them is checked: if the minimum activity in the trough is less than InpBimodalDipRatio * min(peak1, peak2) (the default is 0.55 — that is, a drop of at least 45%), the profile is considered bimodal.
Key peak-detection code fragment:
//+------------------------------------------------------------------+ //| Single Prints Detection (example) | //+------------------------------------------------------------------+ double threshold = 1.5; for(int i = 1; i < levels - 1; i++) { if(activity[i] > 0 && activity[i] <= threshold && activity[i-1] > threshold && activity[i+1] > threshold) { // Draw a dotted single print line } }
The check is performed using an extended neighborhood (not just neighboring levels), which provides resilience to minor noise — local fluctuations in activity on one or two levels that do not constitute a true peak.
When bimodality is confirmed, the indicator does three things. First, it draws the secondary POC as a separate line in a different color (dark orange in the neon theme). Second, it highlights the dead zone — the rectangle between the peaks where activity is below 25% of the height of the smaller peak — with a semi-transparent fill. Third, it updates the status in the information panel. The “BIMODAL” indicator and the “DEAD ZONE” marker appear when the current price is between the clusters.
The trading interpretation of the dead zone is fundamentally different from that of a classic Value Area. Within a true Value Area, price fluctuates around the POC and returns to it — this is a zone of balance. Within the dead zone, by contrast, price shoots through quickly — this is a zone of imbalance. Understanding this difference changes trading decisions: mean-reversion trades in the dead zone are statistically prone to fail, while breakout trades, by contrast, have a higher probability of success. The classic Market Profile does not show this difference at all.
Problem No. 2: TPO does not show who the aggressor was
Another conceptual issue with Market Profile is that it shows where price lingered, but not who the aggressor was at each level. This is important because two sessions with the same POC can have completely different internal structures.
Imagine two situations. In the first scenario, price fluctuates around the 1.0830 level for several hours, and buyer aggressors dominate at this level — they are constantly lifting the ask, while sellers defend with limit orders. The POC is formed during the accumulation of long positions. In this situation, a breakout above the POC becomes the natural next step: buyers who had accumulated positions have waited for the limit sellers to become exhausted. In the second scenario, at the same 1.0830 level, the POC is formed under the dominance of seller aggressors: they hit the bid, while buyers hold support with limit orders. A break to the downside is likely here.
In both situations, classic Market Profile shows the same picture: a clean POC at 1.0830 with a symmetrical Value Area. For a trader, this provides zero information about future direction.
The solution is Delta Profile: integrating order flow. The idea is simple: alongside the activity profile, we build a delta profile — that is, the net difference between buyer-aggressor and seller-aggressor volume at each price level. Visually, it is drawn as a mirror image: if the main profile extends to the right of the session start-time axis, the delta profile extends to the left. Green bars represent buyer pressure, and red bars represent seller pressure.
A critical technical detail: where to get information about aggressors. In MetaTrader 5, there is an ideal source for exchange-traded instruments — the CopyTicksRange function with the COPY_TICKS_TRADE flag, which returns an MqlTick array with a flags field. The TICK_FLAG_BUY and TICK_FLAG_SELL flags precisely determine the aggressor side for each trade:
//+------------------------------------------------------------------+ //| Retrieving real ticks with aggressor flags | //+------------------------------------------------------------------+ MqlTick ticks[]; int copied = CopyTicksRange(_Symbol, ticks, COPY_TICKS_TRADE, (ulong)prof.start_time * 1000, (ulong)prof.end_time * 1000); if(copied > 0) { for(int i = 0; i < copied; i++) { double p = ticks[i].last; if(p <= 0) p = (ticks[i].bid + ticks[i].ask) / 2.0; if(p <= 0) continue; int idx = (int)MathRound((p - s_low) / g_tick_size); if(idx < 0 || idx >= levels) continue; double vol = (ticks[i].volume_real > 0) ? ticks[i].volume_real : (double)ticks[i].volume; if(vol <= 0) vol = 1.0; if((ticks[i].flags & TICK_FLAG_BUY) != 0) delta_out[idx] += vol; else if((ticks[i].flags & TICK_FLAG_SELL) != 0) delta_out[idx] -= vol; } return; }
For Forex, where there is no real tick data with aggressor classification, we have a fallback algorithm. It uses the direction of the bar close relative to its open as a proxy for the delta sign, while the distribution of volume across levels within the bar is weighted by proximity — that is, by how close the level is to the close price. It is not perfect, but in practice it provides a meaningful signal — bars with strong upward closes do tend to indicate buying pressure, and vice versa.
A key feature of our implementation is absorption detection. This is one of the most frequently traded patterns in order-flow trading. Absorption occurs when delta is significantly negative at the POC, where activity is at its maximum. This means that at this level, sellers were constantly attacking, but buyers kept absorbing everything through limit orders. When buyers run out of steam, a sharp downward move will begin. And vice versa for the opposite situation.
Mathematically, we detect this as follows: if |delta_at_poc| / activity_at_poc > InpAbsorptionThresh (default 0.4), the aggressor's share at the POC is considered to exceed the threshold, and the absorption_poc flag is set. In the visual layer, a "⚡ ABSORPTION" label appears next to the POC; the corresponding status appears in the information panel; and, if alerts are enabled, a notification is generated. This is one of those signals that traders pay USD 200 a month for in Bookmap and ATAS terminals. We got it for free in MetaTrader 5.
Problem No. 3: The market remembers, but the indicator does not
The third conceptual problem with the classic Market Profile is the lack of long-term memory. Each session is calculated in isolation. But the real market works differently: zones where a large profile formed five sessions ago continue to act as magnets and repellers. When we look at the chart, our brain remembers this and takes it into account. The indicator does not.
This is particularly problematic in the case of the Naked POC concept — an untested POC from the previous session. Empirically, a POC that the market has not yet returned to test after the end of the session that formed it is one of the strongest magnets in price action. Statistically, price returns to a naked POC within a few sessions with a probability significantly higher than chance. This is one of the few trading formations that actually work and have been validated by academic research on S&P futures data. However, to use it, a trader needs to keep track of the POCs for the last 5, 10, or 20 sessions and note which ones have not yet been tested.
The solution is Composite Memory with exponential decay. Our indicator automatically calculates a composite profile over the last N sessions (configurable via InpCompositeSessions; default is 20), applying a weight of decay^s to each session, where decay is the decay coefficient (default 0.92) and s is the age of the session. This creates a structure similar to human memory: recent sessions are more important, but older ones are not completely forgotten.
Implementation:
//+------------------------------------------------------------------+ //| Building a composite profile with exponential decay | //+------------------------------------------------------------------+ for(int s = 0; s < sessions_to_use; s++) { double decay_weight = MathPow(InpDecayFactor, s); double s_high = profiles[s].high; double s_low = profiles[s].low; if(s_high <= s_low) continue; double poc = profiles[s].poc_price; double vah = profiles[s].vah_price; double val = profiles[s].val_price; double total = profiles[s].total_activity; for(int i = 0; i < c_levels; i++) { double p = g_composite_prices[i]; if(p < s_low || p > s_high) continue; double contribution; if(p >= val && p <= vah) { double dist_from_poc = MathAbs(p - poc); double va_half = MathMax(vah - poc, poc - val); if(va_half <= 0) va_half = g_tick_size; contribution = total * 0.7 * (1.0 - dist_from_poc / (va_half * 2.0)); } else { double range = s_high - s_low; contribution = total * 0.3 / MathMax(range / g_tick_size, 1.0); } if(contribution < 0) contribution = 0; g_composite_activity[i] += contribution * decay_weight; } }
Note the architectural decision: we do not store the full profile of each session, but instead reconstruct the distribution from the POC, VAH, VAL, and total activity volume. This works like a triangular distribution with a peak at the POC and its base spanning the Value Area — a fairly accurate approximation of the real profile with significant memory savings. With twenty sessions and three hundred levels per session, the full version would require 6,000 double values, while ours requires only 80 scalar parameters.
Three types of objects are extracted from the assembled composite:
- HVN (High Volume Nodes) — local maxima of composite activity exceeding 70% of the absolute maximum. These are stable zones of concentration over a long period — “balances” in the terminology of classical Market Profile theory. They are drawn as solid purple lines to the right of the current session, extending forward as a forward ray.
- LVN (Low Volume Nodes) — local minima below 15% of the maximum. These are areas of low volume that price typically moves through quickly. Visually: a yellow dotted line labeled “LVN.”
- Naked POCs — a separately tracked set. The algorithm iterates through the POCs of past sessions and checks, for each one, whether the price touched it after the corresponding session closed. If no subsequent bar falls within the range [POC - tick, POC + tick], the POC is considered “naked” and is plotted as a bright pink dash-dot line with an age label — “nPOC 3d,” “nPOC 7d.” By default, we display up to ten items, subject to the InpMaxNakedPOCs limit.
//+------------------------------------------------------------------+ //| Checking and rendering untested POCs (Naked POCs) | //+------------------------------------------------------------------+ for(int s = 1; s < sessions_to_check && naked_drawn < InpMaxNakedPOCs; s++) { double poc = profiles[s].poc_price; int session_end_bar = profiles[s].end_bar; bool tested = false; for(int b = session_end_bar - 1; b >= 0; b--) { if(poc >= low[b] && poc <= high[b]) { tested = true; break; } } if(!tested) { // Draw a naked POC line and label } }
Taken together, these three mechanisms transform Market Profile from a snapshot of the current session into a comprehensive map of the market's memory. A trader sees not only what is happening today, but also where the market has historically tended to return and which levels are still waiting to be tested.
Solution Architecture: Three Breakthrough Mechanisms in a Single Indicator
By combining all three improvements, we end up with an indicator of a fundamentally different class. To ensure that all of this works together seamlessly, the architecture is structured as follows.
The central data structure is SessionProfile — a container that stores all the characteristics of a single session:
//+------------------------------------------------------------------+ //| Session profile | //+------------------------------------------------------------------+ struct SessionProfile { datetime start_time; datetime end_time; int start_bar; int end_bar; double high; double low; double poc_price; double secondary_poc; // Bimodal: secondary POC (0 if unimodal) double dead_zone_top; // Bimodal: dead zone bounds double dead_zone_bot; bool is_bimodal; double vah_price; double val_price; double vah2_price; // Bimodal: second VA double val2_price; double total_activity; double delta_total; // Delta: net buy-sell double delta_at_poc; // Delta: delta at POC price bool absorption_poc; // Delta: absorption detected at POC };
This is not just a technical container — it is a conceptual statement of what a session is in our indicator. In classic MP, a session is described by four numbers: high, low, POC, and VA. In ours, it is described by fifteen, and each of them carries trading-relevant information.
First, the number of sessions to be built is determined: the greater of the number of visible sessions and the number of sessions for composite memory. Then, within the loop, each session is processed by the `BuildSessionProfile` function, which uses the `draw_visuals` flag to determine whether to render visual elements or simply calculate the structure for further aggregation. After all sessions have been processed, three additional blocks are enabled: developing levels for the current session, composite memory for historical analysis, and the alerts and dashboard update block.
A critically important architectural detail is the separation of computation and visualization. For composite memory, we need data from twenty sessions, but we only want to render five of them. The `draw_visuals` flag allows you to recalculate old sessions without creating thousands of invisible graphical objects that would unnecessarily slow down the terminal.
Implementation: Line-by-line analysis of key functions
Let's take a closer look at a few critical functions.
The adaptive PointMultiplier — the CalculatePointMultiplier() function — solves the age-old Market Profile problem: how to choose the correct price grid step for any instrument. The standard approach hard-codes the step size in points, which works for a familiar instrument but fails for a new one. Our solution uses ATR(14) as a measure of current volatility and selects a step size such that a typical daily range corresponds to approximately 150 levels:
//+------------------------------------------------------------------+ //| Calculate adaptive point multiplier | //+------------------------------------------------------------------+ void CalculatePointMultiplier() { double atr = 0; int h = iATR(_Symbol, _Period, 14); if(h != INVALID_HANDLE) { double buf[1]; if(CopyBuffer(h, 0, 0, 1, buf) == 1) atr = buf[0]; IndicatorRelease(h); } if(atr <= 0) atr = SymbolInfoDouble(_Symbol, SYMBOL_BID) * 0.005; double raw = atr / _Point; g_point_mult = (int)MathMax(1, MathRound(raw / 150.0)); }
The fallback logic used when ATR is unavailable — 0.5% of the price — provides a reasonable approximation for most instruments. For EURUSD, the multiplier is about 5; for Bitcoin, about 100; and for Sberbank, about 10. In all cases, the profile is drawn with meaningful detail.
The bimodality detector — the DetectBimodal() function — is an example of how an academic algorithm is transformed into practical code. In theory, the correct way to test for bimodality is Hartigan's Dip Test, but implementing it requires a significant amount of supporting code. A practical compromise is to find the two most significant peaks, with additional constraints and a check of the trough depth:
//+------------------------------------------------------------------+ //| Search for all local maxima subject to constraints | //+------------------------------------------------------------------+ for(int i = 1; i < levels - 1; i++) { bool is_peak = true; int check_range = MathMax(2, InpMinPeakDistance / 2); for(int d = 1; d <= check_range; d++) { int left = i - d; int right = i + d; if(left >= 0 && activity[left] >= activity[i]) { is_peak = false; break; } if(right < levels && activity[right] > activity[i]) { is_peak = false; break; } } if(is_peak && activity[i] >= min_peak_height) { int sz = ArraySize(peaks); ArrayResize(peaks, sz + 1); ArrayResize(peak_vals, sz + 1); peaks[sz] = i; peak_vals[sz] = activity[i]; } }
Note the asymmetry in the comparison: on the left, >= is used, and on the right, > is used. This is a standard technique for eliminating false peaks in flat areas of activity, where several adjacent levels may have the same maximum height.
Delta Profile with fallback — the ComputeDeltaProfile() function — demonstrates the "use the best available" approach. First, we try real tick data. If there is none or too little of it (for example, for Forex instruments), we switch to a proxy based on the direction of the bar's close. All the logic is encapsulated in a single function; the calling code does not need to know how the delta was obtained:
//+------------------------------------------------------------------+ //| Proxy delta calculation based on bar direction | //+------------------------------------------------------------------+ double bias = (c > o) ? 0.65 : (c < o) ? -0.65 : 0.0; int lo_idx = (int)MathMax(0, MathRound((l - s_low) / g_tick_size)); int hi_idx = (int)MathMin(levels - 1, MathRound((h - s_low) / g_tick_size)); int span = hi_idx - lo_idx + 1; double per_level = vol / span; for(int i = lo_idx; i <= hi_idx; i++) { double dist_ratio = (h > l) ? (prices[i] - l) / (h - l) : 0.5; double close_ratio = (h > l) ? (c - l) / (h - l) : 0.5; double proximity = 1.0 - MathAbs(dist_ratio - close_ratio); double level_bias = bias * proximity; delta_out[i] += per_level * level_bias; }
The magic number 0.65 is the bias value for a bar with a significant direction. It has been calibrated empirically: for values below 0.5, the delta is too weak; for values above 0.8, it is too noisy. 0.65 provides a visually reasonable representation for most instruments.
Visual layer: neon palette, glow effects, and a glass dashboard
A good analytical tool should not only be accurate, but also visually appealing. This is not just about aesthetics — visual hierarchy directly influences the speed of decision-making. An indicator in which all elements are rendered with the same washed-out look forces the trader to spend cognitive resources identifying what is important. An indicator with a well-thought-out visual hierarchy delivers information straight to the trader's intuition.
Our indicator features a theme system — five predefined palettes plus a custom palette. By default, Neon Dark features a black-and-blue background with acid-bright accents: cyan for the Value Area, gold for the POC, and magenta for Single Prints. Sunset — a wine-and-coral color palette. Ocean is a deep blue theme with a white crest at the POC. Matrix — cyber aesthetics in shades of green. Royal — purple with royal gold.
Each theme is defined using the ThemePalette structure, which contains fourteen colors:
//+------------------------------------------------------------------+ //| Theme palette structure | //+------------------------------------------------------------------+ struct ThemePalette { color profile_base; color profile_accent; color va_color; color va_accent; color poc_color; color poc_glow; color single_print; color panel_bg; color panel_border; color panel_text; color panel_accent; color divider; color label_shadow; color secondary_poc; color dead_zone; color delta_buy; color delta_sell; color absorption; color hvn_color; color lvn_color; color naked_poc; color composite_bg; };

The glow effect on the POC is implemented through multi-layer rendering. Several concentric rectangles of increasing size and decreasing color intensity are drawn around the main POC bar. This creates the visual impression of a glow:
//+------------------------------------------------------------------+ //| Glow effect for POC | //+------------------------------------------------------------------+ if(is_poc && InpGlowEffect && session_index == 0) { for(int g = InpGlowIntensity; g > 0; g--) { double glow_width_frac = 1.0 + g * 0.05; long glow_width = (long)(bar_width_sec * glow_width_frac); datetime glow_end = (datetime)(t_start + glow_width); color glow_clr = DimColor(g_palette.poc_glow, 0.3 + 0.2 * (InpGlowIntensity - g)); // create a semi-transparent rectangle } }
A similar technique is applied to the POC line: first, a thick colored backing line is drawn, and then a thin main line is drawn on top of it. Visually, this is perceived as a glow. By default, the effect is enabled only for the current session so as not to overload the chart.
Gradient coloring of the bars is implemented using the BlendColors() function, which performs linear interpolation between two colors in RGB space:
//+------------------------------------------------------------------+ //| Linear interpolation between two colors | //+------------------------------------------------------------------+ color BlendColors(const color c1, const color c2, const double t) { double tt = MathMax(0.0, MathMin(1.0, t)); int r1 = (c1 & 0xFF); int g1 = ((c1 >> 8) & 0xFF); int b1 = ((c1 >> 16) & 0xFF); int r2 = (c2 & 0xFF); int g2 = ((c2 >> 8) & 0xFF); int b2 = ((c2 >> 16) & 0xFF); int r = (int)(r1 + (r2 - r1) * tt); int g = (int)(g1 + (g2 - g1) * tt); int b = (int)(b1 + (b2 - b1) * tt); return (color)((b << 16) | (g << 8) | r); }
A bar with low activity is drawn in a muted base color, while a bar with high activity is drawn in a bright accent color. This creates a natural sense of “warmth” around high peaks without requiring complex information encoding.
Depth dimming for older sessions is another visual technique. Sessions are progressively dimmed with age using the DimColor() function: 100% for today’s session, 88% for yesterday’s, 76% for the day before yesterday’s, and so on. This creates an illusion of depth: recent sessions are literally “closer” to the trader’s eye, while older ones recede into the background.
The dashboard in the top-right corner is an attempt to turn Market Profile into a full-fledged analytical dashboard. The panel is rendered as a multi-layered object: a dark background with a side shadow, an accent top strip in the theme color, and a series of text fields with key information: POC, POC² (secondary POC in the case of bimodality), VAH, VAL, Delta Total, absorption status, current price, structural classification (NORMAL / BIMODAL), and zone identification (AT POC / IN VA / ABOVE VA / BELOW VA / DEAD ZONE). All of this updates in real time, allowing the trader to assess the market situation at a glance without having to study the chart.
Practical Application: Three Scenarios for Real Trading
Theoretical elegance must be tested in practice. Let's examine three specific scenarios in which our indicator gives traders an edge that is unavailable with the classic Market Profile.
Scenario 1: Trading from the Naked POC on major FX pairs. This is perhaps the simplest practical application of the indicator, which works on EURUSD, GBPUSD, and USDJPY on the M15–H1 time frames. The method is extremely simple: set the indicator to Weekly sessions mode with InpCompositeSessions = 8. Enable InpShowNakedPOCs. Pink dotted lines will appear on the chart — these represent untested POCs from recent weeks. Each such line is a statistically significant magnet. Trading comes down to waiting for the price to approach a naked POC to within about 10–15 pips, checking that there are no conflicting signals, and entering toward the POC with a target at 50–70% of the distance and a stop beyond the nearest swing. Basic statistics for EURUSD based on 2023–2025 data show a win rate of about 58–62% with an R:R of about 1:1.3, which gives a positive expectancy even after accounting for the spread and slippage.
Scenario 2: Absorption trading on MOEX. This scenario requires an exchange-traded instrument with real tick data—Sberbank or Gazprom futures are ideal. Time frame M5 or M15, Daily session, InpUseRealTicks = true, InpAlertOnAbsorption = true. The indicator will mark moments when absorption forms at the POC of the current session — persistent absorption of aggressors on one side by limit orders on the other side. Trading logic: after an absorption signal is triggered, we wait for the aggressors to be exhausted — technically, for activity at the POC to decline over the next 2–3 bars — and then enter in the direction opposite to the aggressors. Stop — beyond the absorption zone’s extreme. The target is the VAL or VAH on the opposite side. This is a classic “crowd trap”: the aggressors kept pushing and pushing, but the buyers/sellers with limit orders absorbed them, and now the market moves in the opposite direction. The signals are infrequent — 1–3 per week on liquid futures — but they are very high-quality.
Scenario 3: Avoiding the “dead zone” in grid strategies. For users of grid-based Expert Advisors (including my CA_Grid_ULTIMATUM), our indicator provides important information: where a dead zone is forming in the current session, if there is one. The logic behind this approach is the opposite of the two previous scenarios — we do not trade off levels; instead, we restrict the grid's operation. A filter is added to the grid EA's settings: if the price is within the dead zone of the bimodal profile, no new grid levels are opened. This protects against a typical cause of grid failure — a situation where the price shoots through the empty zone between clusters and opens a cascade of losing grid orders, with no support for a pullback. In my experience, this single filter reduces the maximum drawdown by about 15–25% without sacrificing profitability.
Performance and Optimization: How to Keep Your Terminal Running Smoothly
Any indicator that draws hundreds of graphical objects is a potential performance killer for the terminal. In our case, the number of objects in a single session can reach 300–400 (profile bars plus delta bars plus level lines plus labels), and with five sessions displayed, it can reach up to 2,000 objects. Without optimization, this would turn the terminal into a slideshow.
There are three main optimizations.
Redraw throttling. The OnCalculate function is called on every tick, but a full redraw of the indicator is needed only when a new bar appears or when there are very significant changes. On ticks of the current bar, we check the time of the last redraw and skip the call if less than InpThrottleMs milliseconds have elapsed:
//+------------------------------------------------------------------+ //| Redraw throttling | //+------------------------------------------------------------------+ if(prev_calculated > 0 && rates_total == prev_calculated) { uint now = GetTickCount(); if(now - g_last_redraw < (uint)InpThrottleMs) return rates_total; g_last_redraw = now; }
Selective object deletion. The DeleteProfileObjects() function deletes only the profile objects but preserves the dashboard. This is crucial because recreating the panel with all its text elements is a costly operation. The panel is updated in place via ObjectSetString():
//+------------------------------------------------------------------+ //| Selective object deletion | //+------------------------------------------------------------------+ int total = ObjectsTotal(0, 0, -1); for(int i = total - 1; i >= 0; i--) { string name = ObjectName(0, i, 0, -1); if(StringFind(name, g_prefix) == 0 && StringFind(name, g_prefix + "PANEL") != 0) ObjectDelete(0, name); }
Limiting the number of price levels by setting InpMaxPriceLevels = 300. Under extreme volatility (for example, a cryptocurrency crash), a naive calculation of the number of levels could produce tens of thousands of values, which would be guaranteed to bring the indicator down. A clamp prevents this from happening. If a trader applies the indicator to such an extreme period, the profile resolution adapts automatically.
As a result, on a mid-range laptop (2023), the full-featured indicator uses about 3–5% of the CPU on the EURUSD M5 chart in real time (5 visible sessions, a 20-session composite, with delta and bimodal enabled). This is comparable to a well-written classical MP without any additional mechanics. We gained three times as much functionality without any loss in performance.
Conclusion
Instead of a cosmetic modernization of the classic approach, we took a practical step: we preserved Steidlmayer's theoretical foundation and addressed three of its key gaps through code and architecture. What you get from this article and the accompanying code: a reference implementation of the profile and Value Area (pairwise expansion); a bimodality detector with a secondary POC, dead zone visualization, and a modified interpretation of balance/imbalance; Delta Profile based on real tick data (CopyTicksRange) with a fallback for FX and an absorption detector at the POC; Composite Memory over N sessions with decay, HVN/LVN extraction, and naked POC tracking; a modular structure (SessionProfile, BuildSessionProfile, computation and visualization layers), optimizations for redraws, and level limiting.
Practical value: the indicator can be placed directly on a chart or used as a library for filters and signals (for example, naked POC for entries, absorption signals for reversal trading, and the dead-zone filter for grid strategies). Limitations and fallbacks are clearly described: for exchange-traded instruments, we use ticks with aggressor classification; for FX, we use a bar-based proxy; performance is protected by throttling and clamps.
Next steps include integrating statistics with ML modules, using MarketBook for Volume-at-Price at the order book level, and multi-symbol mode for spreads. The code is open and documented; it is designed as a platform for further research and customization.
Translated from Russian by MetaQuotes Ltd.
Original article: https://www.mql5.com/ru/articles/17518
Warning: All rights to these materials are reserved by MetaQuotes Ltd. Copying or reprinting of these materials in whole or in part is prohibited.
This article was written by a user of the site and reflects their personal views. MetaQuotes Ltd is not responsible for the accuracy of the information presented, nor for any consequences resulting from the use of the solutions, strategies or recommendations described.
Building A Candlestick Trend Constraint Model (Part 11): Revealing Lower-Timeframe Triggers Within Higher-Timeframe Candle Relationships
Building a Prop Firm Compliance Monitor in MQL5 (Part 3): Daily Drawdown, Overall Drawdown, and Profit Targets
Features of Experts Advisors
Offline Reinforcement Learning in MQL5: Conservative Q-Learning from Historical Trade Logs Without Live Exploration
- Free trading apps
- Over 8,000 signals for copying
- Economic news for exploring financial markets
You agree to website policy and terms of use