preview
A Generic Object Pool in MQL5: Eliminating Heap Fragmentation in High-Frequency Indicators

A Generic Object Pool in MQL5: Eliminating Heap Fragmentation in High-Frequency Indicators

MetaTrader 5Trading systems |
315 0
Ushana Kevin Iorkumbul
Ushana Kevin Iorkumbul

Introduction

Every new operator call in MQL5 requests a contiguous block of memory from the terminal's managed heap. The allocator searches for a free block of sufficient size, marks it as occupied, and returns a pointer. Conversely, the delete operator marks that block as available again. These operations are fast in isolation. However, continuous high-frequency execution introduces overhead that accumulates over time.

The primary cost of repeated new/delete in OnCalculate() is not heap compaction — the specifics of the MQL5 terminal's internal allocator are not publicly documented to that level of detail — but rather the direct overhead of each allocation and deallocation call itself: constructor and destructor invocations, internal allocator bookkeeping, memory initialization, and timing jitter introduced into a hot execution path. Even if individual allocations are fast on average, the unpredictability of their cost matters in a trading context where latency consistency is often more valuable than raw average throughput.

Custom indicators executing OnCalculate() on every tick compound this overhead. During major news events, tick rates can exceed 100 per second. At that speed, the allocator performs two heap operations per tick alongside payload construction and destruction. This workload gradually increases execution time variance across a trading session.

Practical note: For an indicator that uses a single small object per tick, the most straightforward fix is not a pool at all — it is simply promoting that object to a global or class-member variable and calling Reset() on each tick. A pool is the right tool when many short-lived objects of the same type must coexist simultaneously, their count is predictable, and profiling has confirmed allocation as a meaningful contributor to execution time.
The object pool pattern addresses the allocation overhead for this class of workload by front-loading all memory operations into the indicator's initialization phase. A fixed-size block of objects is allocated once inside OnInit(). Thereafter, the indicator draws from and returns objects to this pre-allocated reservoir, bypassing the heap allocator completely on the normal path. This shifts heap operations entirely out of OnCalculate(), eliminating both the overhead and the jitter those calls introduce.

 Side-by-Side Memory State & Lookup Complexity (After 500 Cycles)

Figure 1: Side-by-Side Memory State & Lookup Complexity (After 500 Cycles).



Object Pool Architecture

The pool implementation consists of three components. CSignalEvent is the reusable payload class representing a single signal computation result. CObjectPool<T> is the generic pool engine that manages the lifecycle of any poolable type. A custom indicator PoolBenchmark.mq5 exercises both components under simulated tick conditions and logs measurable timing differences between pooled and unpooled allocation strategies.

The pool operates on a free-list model. At construction, it allocates exactly m_capacity instances of type T on the heap and stores their pointers in a flat array. A second integer array of the same length records the indices of all currently available objects. An integer m_free_count tracks how many entries in that index array are valid.

Acquiring an object decrements m_free_count, reads the slot index stored at m_free_indices[m_free_count], and returns the pointer at m_objects[slot_index]. No heap call is made. Releasing an object reads the slot index stored inside the object itself via GetPoolIndex(), validates the pointer, calls Reset() on the payload fields, clears the in-use flag, and writes the slot index back to the free stack. Both Acquire() and Release() are O(1). No membership scan is required.

CObjectPool internal memory layout showing the relationship between m_free_indices[] and m_objects[].

Figure 2: CObjectPool internal memory layout showing the relationship between m_free_indices[] and m_objects[]. The m_free_count cursor divides the index stack into in-use and available zones. Acquiring an object reads the target slot index and retrieves the corresponding pointer in O(1). Releasing an object reads the slot index stored inside the object itself, resets the payload, and returns the slot to the free stack in O(1).



Why a Free-List Index Array Rather Than a Pointer Stack

A simpler design would store the available pointers themselves in the free array rather than their indices. The index-based design is preferred for two reasons. First, it preserves the ability to validate that a released object actually belongs to this pool instance — the identity check m_objects[slot] == obj confirms this in O(1). Second, it keeps the free array as a plain int[] rather than a T*[], which avoids any pointer-type constraint on the pool's generic parameter and simplifies the ArrayResize operations needed during initialization.

Pool Capacity and Overflow Policy

The pool has a fixed capacity set at construction time. When the pool is exhausted, Acquire() returns NULL. The caller must handle this case. There is no fallback that allocates a new T() from the heap.

Design rationale:An overflow fallback via new T() reintroduces heap allocation into the hot path under the exact conditions where the pool was supposed to eliminate it — namely, peak load. A pool that falls back to the allocator when it matters most delivers neither the predictability of a fixed pool nor the flexibility of pure heap allocation. The correct response to exhaustion is to size the pool correctly in OnInit(), not to silently bypass it at runtime. If sizing requirements are genuinely unpredictable, expand the pool in OnInit() by a safety margin after profiling maximum concurrent live objects.

Decision flowcharts for Acquire() and Release().

Figure 3: Decision flowcharts for Acquire() and Release(). Acquire() returns an object pointer in O(1) when slots are available, or NULL with a journal warning on exhaustion. Release() validates the pointer against double-release via IsInUse(), confirms pool ownership via IsPooled(), then recovers the slot in O(1) via GetPoolIndex() before resetting the payload and returning the slot to the free stack.



The CSignalEvent Payload Class

CSignalEvent represents a single signal computation result carrying a direction, a reference price, a strength value, and a timestamp. It implements the full pool contract that CObjectPool<T> requires.

Reset() clears only the business-state payload fields — m_direction, m_price, m_strength, and m_timestamp. Pool ownership metadata, specifically m_is_pooled, m_pool_index, and m_in_use, is managed exclusively by CObjectPool<T> and is never touched by Reset(). This separation is intentional. A payload class should have no knowledge of the allocator that owns it; embedding lifecycle decisions inside Reset() would couple business logic to memory management, making the class harder to reason about and reuse independently. The pool sets and maintains all ownership state from construction through the full acquire and release cycle.

CSignalEvent Property Table

PropertyTypeDefaultPurpose
m_directionint0Signal direction: 1 long, -1 short, 0 neutral
m_pricedouble0.0Reference price at signal generation time
m_strengthdouble0.0Normalized signal strength value (0.0 to 1.0)
m_timestampdatetime0Server time at point of signal computation
m_is_pooledboolfalsePool ownership flag; set by CObjectPool<T> only
m_pool_indexint -1 Slot index inside pool; enables O(1) Release() 
m_in_usebool false In-use flag; prevents double-release corruption 

CSignalEvent Contract Methods

The constructor initializes payload fields via Reset() and sets pool metadata to safe defaults (m_is_pooled = false, m_pool_index = -1, m_in_use = false). CObjectPool<T> immediately overwrites these with correct values during construction of the pool itself.

Reset() zeros every payload field and nothing else:

//+------------------------------------------------------------------+
//| Reset business-state payload fields to defaults for pool reuse.  |
//| Does NOT touch m_is_pooled, m_pool_index, or m_in_use.           |
//| Pool ownership metadata is the pool's responsibility, not the    |
//| object's payload logic.                                          |
//+------------------------------------------------------------------+
void CSignalEvent::Reset(void)
  {
   m_direction = 0;
   m_price     = 0.0;
   m_strength  = 0.0;
   m_timestamp = 0;
   //--- m_is_pooled, m_pool_index, and m_in_use are intentionally NOT reset here.
   //--- Pool lifecycle metadata is managed exclusively by CObjectPool<T>.
  }

SetPooled() and IsPooled() record whether the object was pre-allocated by a pool. SetPoolIndex() and GetPoolIndex() carry the slot index used by Release() to return the object in O(1). SetInUse() and IsInUse() form the double-release guard: Release() checks IsInUse() before accepting the object and rejects it with a warning if the flag is already false.



The CObjectPool Template Engine

MQL5 supports class templates using the same template<typename T> syntax as C++. The template parameter T in CObjectPool<T> must be a class type that implements the full pool contract: Reset(), SetPooled(bool), IsPooled(), SetPoolIndex(int), GetPoolIndex(), SetInUse(bool), and IsInUse(). If T does not implement these methods, the compiler will emit an error at the point of instantiation rather than at the template definition. This is the expected behavior for MQL5 template classes.

CObjectPool Method Interface Table

MethodReturn TypeAccessMemory Operation
CObjectPool(int capacity)publicAllocates all T instances on the heap; records slot indices; marks all as not-in-use
~CObjectPool()
public
Deletes all T instances in m_objects[]; frees both arrays
Acquire()T*publicO(1): decrements m_free_count, sets in-use flag, returns pointer
Release(T* obj)voidpublicO(1): reads slot index from obj.GetPoolIndex(); validates against double-release; calls obj.Reset(); writes index to free stack
FreeCount()intpublicReturns current m_free_count — no memory operation
Capacity()intpublicReturns m_capacity — no memory operation
Utilization()doublepublicReturns (m_capacity − m_free_count) / m_capacity × 100.0
IsExhausted()boolpublicReturns m_free_count == 0

Constructor

The constructor performs all heap allocation for the lifetime of the pool. Both arrays are sized to m_capacity in a single ArrayResize call each. The loop instantiates every T object, stores its slot index inside the object via SetPoolIndex(), marks it as not-in-use and pool-owned, and records its index in the free stack. After the constructor returns, the heap allocator is not contacted again on the normal acquire/release path.

//+------------------------------------------------------------------+
//| Constructor                                                      |
//+------------------------------------------------------------------+
template<typename T>
CObjectPool::CObjectPool(int capacity)
   : m_capacity(capacity),
     m_free_count(capacity)
  {
   ArrayResize(m_objects,      m_capacity);
   ArrayResize(m_free_indices, m_capacity);

   //--- Instantiate all objects and record their slot indices
   for(int i = 0; i < m_capacity; i++)
     {
      m_objects[i] = new T();
      m_objects[i].SetPoolIndex(i);   // Store slot index inside the object (O(1) release)
      m_objects[i].SetInUse(false);   // Mark as free at construction
      m_objects[i].SetPooled(true);   // All pre-allocated objects belong to this pool
      m_free_indices[i] = i;
     }
  }

Acquire()

Acquire() is the O(1) path. When the pool has available slots, m_free_count is decremented, the slot index at m_free_indices[m_free_count] is read, and the pointer at m_objects[slot] is returned with the in-use flag set. No heap call is made. When the pool is exhausted, NULL is returned and a warning is printed. There is no heap fallback.

//+------------------------------------------------------------------+
//| Acquire an instance pointer from the pool                        |
//| Returns NULL when pool is exhausted; caller must check.          |
//| No heap allocation occurs on this path after construction.       |
//+------------------------------------------------------------------+
template<typename T>
T *CObjectPool::Acquire(void)
  {
   //--- O(1) pool path: return next available object from free stack
   if(m_free_count > 0)
     {
      m_free_count--;
      T *obj = m_objects[m_free_indices[m_free_count]];
      obj.SetInUse(true);
      return(obj);
     }

   //--- Pool exhausted: caller must handle null
   //--- Do NOT fall back to new T() here: doing so reintroduces heap
   //--- allocation into the hot path, defeating the entire purpose of
   //--- the pool. Size the pool correctly in OnInit() instead.
   Print("[CObjectPool] WARNING: Pool exhausted. NULL returned. Increase pool capacity.");
   return(NULL);
  }

Release()

Release() handles four cases: a NULL pointer is silently ignored; a double-release (IsInUse() is false) is rejected with a warning; a pointer not owned by this pool (IsPooled() is false) is rejected with a warning; a valid owned object has its payload reset, its in-use flag cleared, and its slot returned to the free stack in O(1) via the stored index.

//+------------------------------------------------------------------+
//| Release an active object pointer back to the pool               |
//| O(1): uses the slot index stored inside the object itself.       |
//+------------------------------------------------------------------+
template<typename T>
void CObjectPool::Release(T *obj)
  {
   if(obj == NULL)
      return;

   //--- Guard against double-release
   if(!obj.IsInUse())
     {
      Print("[CObjectPool] WARNING: Double-release detected. Object was already free.");
      return;
     }

   //--- Only accept objects that belong to this pool instance
   if(!obj.IsPooled())
     {
      Print("[CObjectPool] WARNING: Released pointer does not belong to this pool. Ignored.");
      return;
     }

   //--- Validate the stored slot index is in range
   int slot = obj.GetPoolIndex();
   if(slot < 0 || slot >= m_capacity || m_objects[slot] != obj)
     {
      Print("[CObjectPool] WARNING: Invalid slot index on released object. Ignored.");
      return;
     }

   //--- Reset business-state payload (ownership metadata is NOT touched by Reset)
   obj.Reset();
   obj.SetInUse(false);

   //--- Return slot to free stack in O(1)
   m_free_indices[m_free_count] = slot;
   m_free_count++;
  }



When to Use a Pool vs. Simpler Alternatives

An object pool is the right choice when: many objects of the same type must be alive simultaneously, their count is bounded and predictable, they are created and released at high frequency, and profiling has confirmed that allocation is a meaningful contributor to execution time.

For indicators that use only one or two objects per tick, a pool is unnecessary architecture. The simpler and equally effective solution is a pre-created global or class-member object reused every tick:

//--- Best option when only one object is needed per tick:
CSignalEvent g_event;   // global or class member - allocated once in OnInit()

//+------------------------------------------------------------------+
//| Initialization                                                   |
//+------------------------------------------------------------------+
int OnCalculate(...)
  {
   g_event.Reset();
   g_event.SetDirection(...);
   g_event.SetPrice(...);
//--- ... use g_event ...
   return(rates_total);
  }

This approach has zero allocation overhead, zero risk of pool exhaustion, no double-release scenario, and no additional contract methods to implement on the payload class. A pool should be considered only after the simpler reuse approach has been outgrown or profiled as insufficient.

The practical ranking of approaches, from simplest and most efficient to most complex:

  1. Local variables without an object — when the data is simple enough that an object adds no architectural value.
  2. One persistent object reused every tick — covers the vast majority of single-object-per-tick indicator patterns.
  3. A pre-allocated array of objects — when a fixed, small number of objects must coexist.
  4. An object pool — when a large, bounded number of identical short-lived objects are acquired and released at high frequency.
  5. new/delete on every tick — avoid this in OnCalculate() and OnTick().



Benchmark Design

The indicator runs two parallel calculation paths on every OnCalculate() call that represents a live incoming tick. The first path creates a CSignalEvent object via new, populates it, reads its fields into local variables, and immediately deletes it. The second path acquires a CSignalEvent from the pool, populates it identically, reads the same fields, and releases it back to the pool.

Both paths perform identical payload work. The only structural difference is the memory acquisition and release mechanism. The benchmark measures this difference, not signal computation cost.

Benchmark context: This is a synthetic micro-benchmark that deliberately isolates allocator overhead by running inp_iterations allocation cycles per tick. Real indicators performing a single object creation per tick will see a smaller absolute difference. The benchmark's value is as a demonstration that allocation overhead exists and is measurable — not as a proof of the magnitude of degradation in any specific production indicator. Profile your own indicator before committing to a pool.

On the first full recalculation pass (prev_calculated == 0), the indicator returns rates_total - 1 without running either benchmark path. Historical bars are left at EMPTY_VALUE. Once a live tick arrives, the current bar begins displaying measurements and updates on every subsequent tick. On a closed market or a slow symbol, the subwindow will remain blank until a live tick is received — this is the correct and intended behavior.

Timing uses GetMicrosecondCount(). The elapsed time for each path is validated against a plausibility range before being written to the buffers. Values below zero (counter reset) or above one million microseconds (system-level interruption) are rejected and the tick is skipped. This prevents transient anomalies from corrupting the session average accumulators.


Dual-path benchmark workflow for OnCalculate().

Figure 4: Dual-path benchmark workflow for OnCalculate(). On the first pass, buffers are set to EMPTY_VALUE and no benchmark runs. On every live tick, both paths execute identical payload work on a CSignalEvent object — the unpooled path via new and delete, the pooled path via Acquire() and Release(). Elapsed microseconds from GetMicrosecondCount() are validated against a plausibility range before being written to the indicator buffers.


Indicator Buffer Implementation Notes

Three implementation details in PoolBenchmark.mq5 affect correct chart rendering and are worth documenting explicitly.

PLOT_EMPTY_VALUE must be EMPTY_VALUE, not 0.0: Setting the empty sentinel to 0.0 causes the chart engine to treat every zero-valued buffer slot — which covers the entire initialized history — as 'no data' and suppress drawing it. Since timing measurements on fast ticks can also legitimately round to values near zero, this caused the subwindow to remain permanently blank. The correct sentinel is EMPTY_VALUE (DBL_MAX), a value that can never appear as a real microsecond measurement. Buffers are initialized with ArrayInitialize(..., EMPTY_VALUE) to match.

ArraySetAsSeries must not be called on INDICATOR_DATA buffers: Indicator buffers bound via SetIndexBuffer(..., INDICATOR_DATA) are managed by the terminal in forward order: index 0 is the oldest bar, index rates_total-1 is the current bar. Calling ArraySetAsSeries(true) on them reverses the caller's index view but not the terminal's internal view, causing writes to land on the wrong bars and producing a blank plot. The fix is to remove all ArraySetAsSeries calls on indicator buffers and address the current bar as rates_total - 1.

DRAW_SECTION is preferred over DRAW_LINE for sparse live-tick data: DRAW_LINE connects every plotted point to the next with a straight line segment, including bridging the gap between the last EMPTY_VALUE historical slot and the first live measurement. On timeframes with infrequent ticks such as H1, this produces a near-vertical launch artifact that misrepresents the data. DRAW_SECTION draws lines only between consecutive non-empty bars and leaves true gaps elsewhere, giving an accurate visual of exactly which bars received live measurements.



Performance Expectations and Constraints

The per-tick timing difference between the two paths scales with tick frequency and allocator pressure. On a high-frequency symbol such as ETHUSD M1, where ticks arrive multiple times per minute, observed values were approximately 25 us unpooled versus 12 us pooled, yielding a ratio near 2.1x. The unpooled line also shows pronounced tick-to-tick variance — zigzagging between measurements — while the pooled line tracks more steadily below it. This variance is the jitter the pool eliminates, and is often more consequential in live trading than the average overhead difference alone.

On a lower-frequency symbol such as EURUSD H1, where ticks arrive minutes apart and the heap is under minimal pressure, observed values were approximately 4–13 us unpooled versus 2–7 us pooled. The ratio remains consistent at roughly 1.9–2.1x, but the absolute numbers are smaller because the allocator is working on a cold, largely unfragmented heap. This directly supports the point that for slow-tick symbols a single reused object is often sufficient and a pool provides negligible marginal benefit over the simpler alternative.

The Overhead Ratio line in the subwindow, which divides the unpooled time by the pooled time on each tick, is the clearest diagnostic. A ratio that rises over the course of a session indicates increasing allocator overhead that the pool eliminates on its path. A flat ratio across the session indicates the allocator is handling the object size and tick rate efficiently enough that either approach is acceptable.

Architectural Constraints

Thread safety: The pool is not thread-safe. MQL5 executes each indicator or EA instance on a single thread, which removes the need for a mutex. If future terminal updates introduce concurrent execution, Acquire() and Release() will require atomic operations on m_free_count.

Fixed sizing: The pool does not resize dynamically. Size it in OnInit() based on profiled maximum concurrent live objects. Add a small safety margin. If the pool is frequently exhausted in production (watch for the NULL warning in the journal), increase the capacity and reattach the indicator.

Memory overhead: The pool holds m_capacity instances of T for the entire indicator session. The total overhead is pool capacity × sizeof(CSignalEvent). For 64 objects this is negligible, but for very large pools or very large T types the fixed footprint should be considered



Conclusion

Repeated heap allocation in high-frequency indicator loops introduces overhead and timing jitter into OnCalculate(), where execution consistency matters more than raw average speed. The object pool pattern addresses this by confining all heap operations to the initialization phase and using O(1) index array operations for ongoing object lifecycle management.

The implementation rests on two core architectural decisions. First, an index-based free list rather than a pointer stack preserves pool membership validation while keeping the internal array type independent of the template parameter. Second, storing the slot index inside each object via SetPoolIndex() eliminates the need for a membership scan on release, making both Acquire() and Release() constant-time operations regardless of pool capacity.

The pool contract enforces a clean separation between business-state payload and lifecycle metadata. Reset() clears only payload fields, leaving ownership state exclusively under the pool's control. A per-object in-use flag guards against double-release corruption, and the fixed-capacity design with no heap fallback ensures the hot path remains allocation-free even under peak load.

The benchmark component provides ongoing diagnostic value in production. Its dual-path timing measurement quantifies whether a specific tick rate and object pattern justifies the pool's fixed memory footprint — equal to pool capacity multiplied by the size of the pooled type. A flat overhead ratio across a session indicates the allocator is handling the workload efficiently. A rising ratio or visible jitter on the unpooled line is the signal that the pool's consistency guarantee is worth the tradeoff.

Before adopting a pool, consider whether a single persistent object reset on each tick would be sufficient. For many indicators it will be. A pool earns its place when concurrent object demand is bounded, predictable, and high-frequency enough that allocation overhead has been measured as a meaningful contributor to execution time.


Programs used in the article:

#NameTypeDescription
1SignalEvent.mqhInclude FileCSignalEvent poolable payload class carrying direction, price, strength, timestamp, plus pool contract fields (m_is_pooled, m_pool_index, m_in_use) with strict metadata/payload separation.
2ObjectPool.mqhInclude FileCObjectPool<T> generic template engine: O(1) Acquire and O(1) Release via stored slot index, double-release protection, fixed capacity with no overflow fallback.
 3PoolBenchmark.mq5Custom IndicatorDual-path benchmark measuring per-tick execution time between heap-allocated and pool-acquired CSignalEvent objects using GetMicrosecondCount(). Uses DRAW_SECTION for honest gap representation, EMPTY_VALUE as the plot sentinel, and rates_total-1 bar indexing without ArraySetAsSeries.
4Generic_Object_Pool_in_MQL5.zipZip ArchiveZip archive containing all the attached files and their paths relative to the terminal's root folder.
Features of Custom Indicators Creation Features of Custom Indicators Creation
Creation of Custom Indicators in the MetaTrader trading system has a number of features.
Market Microstructure in MQL5 (Part 5): Microstructure Noise Market Microstructure in MQL5 (Part 5): Microstructure Noise
The article extends MicroStructure_Foundation.mqh with a MicrostructureAnalysis struct and five functions that decompose M1 price variation into a quoted spread proxy, Roll-implied spread, OHLC-based noise ratio, order imbalance, and an adverse selection component. A wrapper populates these fields and links them to the volatility suite from Part 4. Empirical thresholds come from 602 NQ E-mini NY sessions (Jan 2024–Jun 2026), helping you gate volatility signals, size risk, and recognize spread-driven frictions.
Features of Experts Advisors Features of Experts Advisors
Creation of expert advisors in the MetaTrader trading system has a number of features.
CSV Data Analysis (Part 3): Engineering a Python Analytics Pipeline for MetaTrader 5 CSV Exports CSV Data Analysis (Part 3): Engineering a Python Analytics Pipeline for MetaTrader 5 CSV Exports
MetaTrader 5 provides rich performance data but limited structural analysis. This article shows how to export results to CSV from MQL5 and build five Python visualizations that expose cross-asset parameter consistency, the lag‑versus‑noise trade-off, walk‑forward decay, drawdown depth and duration, and intraday hour‑by‑day clusters. A unified automation module runs the full pipeline on any new export to deliver repeatable diagnostics.