preview
Lazy-Loading Indicator Handles in MQL5: A Resource Manager Pattern for Multi-Timeframe EAs

Lazy-Loading Indicator Handles in MQL5: A Resource Manager Pattern for Multi-Timeframe EAs

MetaTrader 5Trading systems |
257 1
Ushana Kevin Iorkumbul
Ushana Kevin Iorkumbul

Introduction

A multi-timeframe Expert Advisor (EA) trading 10 symbols across four timeframes with five indicators per combination requires 200 indicator handles for full coverage. Conventionally, developers initialize all handles inside OnInit(). Before OnInit() returns, the terminal connects to each price feed, verifies data availability, allocates indicator buffers, and registers the handles. This process introduces initialization latency. This latency increases during terminal cold starts or when synchronizing stale historical data.

Furthermore, many of these handles are rarely used simultaneously. A strategy may only generate active signals on a small fraction of its watched symbols at any given time. The remaining inactive handles consume memory and handle slots without contributing to the current session.

Static initialization also increases code maintenance overhead. If indicator requirements change, developers must manually update the handle instantiations in OnInit(). Omitted or forgotten handles waste runtime resources while remaining undetected by the compiler.

The lazy-loading resource manager pattern resolves these inefficiencies through three core mechanisms:

  • On-Demand Initialization: Indicator handles are created only upon their first runtime request. This shifts initialization costs from theoretical use cases to active ones.
  • Reference Counting: Identical indicator configurations are shared. For example, if two separate trading modules request the same Average True Range (ATR) parameters on the same symbol and timeframe, the manager creates the handle once and tracks its active consumers.
  • Centralized Ownership: Resource cleanup is consolidated. Instead of maintaining an extensive list of manual IndicatorRelease() calls within OnDeinit(), the EA triggers a single cleanup method from the resource cache.

Cache Decision Path

Figure 1 – Cache Decision Path. When AcquireHandle() is called, the cache checks for an existing key. On cache miss, a new indicator handle is created. On cache hit, the existing handle is returned immediately. Both paths converge to return a valid handle to the caller.



Cache Architecture and Composite Key Design

The cache stores entries indexed by a composite string key constructed from four components: symbol, timeframe, indicator type identifier, and a serialized parameter string. Two requests are considered identical only when all four components match. A request for ATR(14) on EURUSD H1 and a request for ATR(21) on EURUSD H1 produce different keys and different handles.

Composite Key Components

ComponentExampleNote
SymbolEURUSDRaw symbol name as returned by SymbolName()
TimeframePERIOD_H1Full string via EnumToString(ENUM_TIMEFRAMES); produces PERIOD_H1 not H1
Indicator Type0Integer value of ENUM_INDICATOR_TYPE cast to string via IntegerToString()
Parameters14Underscore-delimited parameter values

The key for ATR(14) on EURUSD H1 becomes EURUSD_PERIOD_H1_0_14, where 0 is the integer value of INDT_ATR. For a moving average with period 21, shift 0, method EMA, applied to close on EURUSD H1, the key becomes EURUSD_PERIOD_H1_1_21_0_1_1, where 1 is the integer value of INDT_MA. BuildKey() uses IntegerToString((int)ind type), so the key stores the enum's integer value rather than its label. Serialize parameters with IntegerToString() and fixed-precision DoubleToString(). Avoid StringFormat() to prevent locale-dependent decimal separators and inconsistent keys.

Cache Entry Structure

FieldTypePurpose
m_keystringComposite identity string for lookup
m_handleintTerminal indicator handle
m_ref_countintNumber of active acquisitions
m_symbolstringSymbol component of the key
m_timeframeENUM_TIMEFRAMESTimeframe component of the key
m_indicator_typeENUM_INDICATOR_TYPEIndicator type enum value
m_paramsstringSerialized parameter string
m_memory_est_kbdoubleApproximate memory footprint in KB

IndicatorEntry.mqh

IndicatorEntry.mqh defines the ENUM_INDICATOR_TYPE enumeration and the SCacheEntry struct that backs every slot in the cache array.

//+----------------------------------------------------------------------+
//|                                               IndicatorEntry.mqh     |
//| SCacheEntry: metadata structure per cache entry for CIndicatorCache. |
//| Holds the handle, reference count, composite key, and memory         |
//| estimate for one cached indicator instance.                          |
//|                                                                      |
//| Note: enum member names use the INDT_ prefix to avoid conflict       |
//| with MQL5's built-in ENUM_INDICATOR members (IND_ATR etc.)           |
//+----------------------------------------------------------------------+
#ifndef INDICATORENTRY_MQH
#define INDICATORENTRY_MQH

//+------------------------------------------------------------------+
//| ENUM_INDICATOR_TYPE                                              |
//| Purpose: Identifies the specific indicator subclass type         |
//+------------------------------------------------------------------+
enum ENUM_INDICATOR_TYPE
  {
   INDT_ATR      = 0,  // Average True Range
   INDT_MA       = 1,  // Moving Average
   INDT_RSI      = 2,  // Relative Strength Index
   INDT_MACD     = 3,  // MACD
   INDT_BBANDS   = 4,  // Bollinger Bands
   INDT_STOCH    = 5,  // Stochastic Oscillator
   INDT_CCI      = 6,  // Commodity Channel Index
   INDT_ADX      = 7,  // Average Directional Index
   INDT_ICHIMOKU = 8,  // Ichimoku Kinko Hyo
   INDT_DEMA     = 9   // Double Exponential Moving Average
  };

//+------------------------------------------------------------------+
//| SCacheEntry                                                      |
//| Purpose: Structure holding descriptor data for cache tracking    |
//+------------------------------------------------------------------+
struct SCacheEntry
  {
   string               m_key;              // Composite identity string
   int                  m_handle;           // Terminal indicator handle
   int                  m_ref_count;        // Number of active acquisitions
   string               m_symbol;           // Symbol component
   ENUM_TIMEFRAMES      m_timeframe;        // Timeframe component
   ENUM_INDICATOR_TYPE  m_indicator_type;   // Indicator type component
   string               m_params;           // Serialized parameter string
   double               m_memory_est_kb;    // Approximate memory footprint (KB)

   //--- Default constructor initialization
   SCacheEntry(void)
      :  m_key(""),
         m_handle(INVALID_HANDLE),
         m_ref_count(0),
         m_symbol(""),
         m_timeframe(PERIOD_CURRENT),
         m_indicator_type(INDT_ATR),
         m_params(""),
         m_memory_est_kb(0.0)
     {
      //--- Entry structural setup completed
     }
  };

#endif // INDICATORENTRY_MQH
//+------------------------------------------------------------------+

SCacheEntry stores everything the cache needs to manage one indicator handle: the composite key for lookup, the terminal handle integer, a reference count, and the per-entry memory estimate used by the statistics tracker. The default constructor initializes all fields to zero/default values so that an uninitialized entry is always in a safe, detectable state (m_handle == INVALID_HANDLE, m_ref_count == 0).

Cache Lifecycle Events

EventInternal ActionJournal Output
First request for a keyCreate handle; store entry; set ref count to 1[CREATE] EURUSD_PERIOD_H1_0_14
Subsequent request for existing keyIncrement ref count; return stored handle[HIT] EURUSD_PERIOD_H1_0_14
Release request; ref count > 1Decrement ref count[RELEASE] EURUSD_PERIOD_H1_0_14 refs=1
Release request; ref count reaches 0Call IndicatorRelease(); remove entry[DESTROY] EURUSD_PERIOD_H1_0_14
EA shutdown (OnDeinit)Release all remaining entries regardless of ref count[FLUSH] cache cleared, N handles released

CacheStatistics.mqh

CacheStatistics.mqh implements the CCacheStatistics class, which accumulates hit and miss counts and tracks estimated memory consumption across the session.

//+------------------------------------------------------------------+
//|                                              CacheStatistics.mqh |
//| CCacheStatistics: tracks hit count, miss count, hit rate, and    |
//| estimated memory usage across the indicator cache session.       |
//+------------------------------------------------------------------+
#ifndef CACHESTATISTICS_MQH
#define CACHESTATISTICS_MQH

//+------------------------------------------------------------------+
//| CCacheStatistics                                                 |
//| Purpose: Performance analytics and telemetry for indicator reuse |
//+------------------------------------------------------------------+
class CCacheStatistics
  {
private:
   long                 m_hit_count;         // Total successful cache reuses
   long                 m_miss_count;        // Total new handle creations
   double               m_memory_est_kb;     // Aggregate estimated memory in KB

public:
                        CCacheStatistics(void);

   void                 RecordHit(void);
   void                 RecordMiss(double entry_memory_kb);
   void                 RecordRelease(double entry_memory_kb);
   void                 Reset(void);

   long                 GetHitCount(void)     const;
   long                 GetMissCount(void)    const;
   double               GetHitRate(void)      const;
   double               GetMemoryEstKB(void)  const;
   long                 GetTotalRequests(void) const;
  };

//+------------------------------------------------------------------+
//| Constructor                                                      |
//+------------------------------------------------------------------+
CCacheStatistics::CCacheStatistics(void)
   :  m_hit_count(0),
      m_miss_count(0),
      m_memory_est_kb(0.0)
  {
   //--- Initialized metric values
  }

//+------------------------------------------------------------------+
//| RecordHit                                                        |
//+------------------------------------------------------------------+
void CCacheStatistics::RecordHit(void)
  {
   m_hit_count++;
  }

//+------------------------------------------------------------------+
//| RecordMiss                                                       |
//+------------------------------------------------------------------+
void CCacheStatistics::RecordMiss(double entry_memory_kb)
  {
   m_miss_count++;
   m_memory_est_kb += entry_memory_kb;
  }

//+------------------------------------------------------------------+
//| RecordRelease                                                    |
//+------------------------------------------------------------------+
void CCacheStatistics::RecordRelease(double entry_memory_kb)
  {
   m_memory_est_kb -= entry_memory_kb;
   if(m_memory_est_kb < 0.0)
     {
      m_memory_est_kb = 0.0;
     }
  }

//+------------------------------------------------------------------+
//| Reset                                                            |
//+------------------------------------------------------------------+
void CCacheStatistics::Reset(void)
  {
   m_hit_count     = 0;
   m_miss_count    = 0;
   m_memory_est_kb = 0.0;
  }

//+------------------------------------------------------------------+
//| GetHitCount                                                      |
//+------------------------------------------------------------------+
long CCacheStatistics::GetHitCount(void) const
  {
   return(m_hit_count);
  }

//+------------------------------------------------------------------+
//| GetMissCount                                                     |
//+------------------------------------------------------------------+
long CCacheStatistics::GetMissCount(void) const
  {
   return(m_miss_count);
  }

//+------------------------------------------------------------------+
//| GetHitRate                                                       |
//+------------------------------------------------------------------+
double CCacheStatistics::GetHitRate(void) const
  {
   long total = m_hit_count + m_miss_count;
   if(total == 0)
     {
      return(0.0);
     }
   return((m_hit_count / (double)total) * 100.0);
  }

//+------------------------------------------------------------------+
//| GetMemoryEstKB                                                   |
//+------------------------------------------------------------------+
double CCacheStatistics::GetMemoryEstKB(void) const
  {
   return(m_memory_est_kb);
  }

//+------------------------------------------------------------------+
//| GetTotalRequests                                                 |
//+------------------------------------------------------------------+
long CCacheStatistics::GetTotalRequests(void) const
  {
   return(m_hit_count + m_miss_count);
  }

#endif // CACHESTATISTICS_MQH
//+------------------------------------------------------------------+

RecordMiss() increments the miss counter and adds the per-entry memory estimate to the running total. RecordRelease() subtracts it, clamping at zero to guard against floating-point underflow if the subtraction order is somehow inverted. GetHitRate() returns a percentage by dividing hits by total requests, guarded by a zero-total check that prevents division by zero on the first call.

Figure 2 – Bulk Pre‑Acquisition at Startup. During OnInit(), the cache creates all 30 required indicator handles (5 symbols × 2 timeframes × 3 indicators) in a single batch. During OnTick(), every AcquireHandle() request finds its key already cached, resulting in zero creation calls during runtime.


Indicator Type Enumeration

The cache requires a typed enumeration that maps to MQL5's native indicator creation functions. Each enum value has a corresponding creation branch in the cache's internal factory method. The member names use the INDT_ prefix to avoid a naming conflict with MQL5's built-in ENUM_INDICATOR enumeration, which already defines identically named members such as IND_ATR, IND_MA, and IND_RSI in the global namespace. Using the same names as the built-in enum causes a compiler error regardless of which enumeration they belong to, because MQL5 enum members share a flat global identifier space.

enum ENUM_INDICATOR_TYPE
{
   INDT_ATR      = 0,  // Average True Range
   INDT_MA       = 1,  // Moving Average
   INDT_RSI      = 2,  // Relative Strength Index
   INDT_MACD     = 3,  // MACD
   INDT_BBANDS   = 4,  // Bollinger Bands
   INDT_STOCH    = 5,  // Stochastic Oscillator
   INDT_CCI      = 6,  // Commodity Channel Index
   INDT_ADX      = 7,  // Average Directional Index
   INDT_ICHIMOKU = 8,  // Ichimoku Kinko Hyo
   INDT_DEMA     = 9   // Double Exponential Moving Average
};

Complexity Analysis

ArchitectureLookup CostHandle CreationMemory Behavior
Eager init in OnInit()N/A — all created upfrontO(N) at startup regardless of usagePeak at startup; constant thereafter
Sequential recreation per tickO(1) per call but N creations per sessionO(N) cumulativeFragmentation risk from repeated alloc/free
Linear search cacheO(N) per lookupO(1) amortized after first accessStable; grows only as needed
Keyed string cache (this design)O(1) average with string comparisonO(1) amortized after first accessStable; bounded by actual usage

Average lookup is O(1) in practice because the linear scan is limited by the number of active handles, not the theoretical maximum. For a ten-symbol, four-timeframe, five-indicator EA, the maximum possible entries is two hundred. In practice, active entries at any tick are typically under twenty. The scan length stays short, and the string comparison cost is fixed for a given key length.


Reference Counting Mechanics

Reference counting in this context is a simple integer increment and decrement protocol. When a consumer calls AcquireHandle(), the cache checks whether the key exists. If it does, the ref count increments and the handle is returned. If it does not, a new handle is created, stored with ref count 1, and returned. When a consumer calls ReleaseHandle(), the ref count decreases. When it reaches zero, IndicatorRelease() is called and the entry is removed from the cache array.

Ownership is explicit: the cache owns all handles, and consumers only borrow the handle IDs. They hold a handle integer for the duration of their use, but they do not call IndicatorRelease() directly. They call ReleaseHandle() on the cache, which decides whether the underlying resource can be freed based on the ref count. This centralization means that no individual module needs to track whether it is the last user of a given handle.

Reference Count Lifecycle

Figure 3 – Reference Count Lifecycle. The first AcquireHandle() creates the handle and sets the reference count to 1. Each subsequent AcquireHandle() increments the count. Each ReleaseHandle() decrements the count. IndicatorRelease() executes only when the count reaches zero, regardless of how many modules borrowed the handle.

The overhead introduced by reference counting is two integer operations per acquire-release cycle, one array scan bounded by active entry count, and one string comparison per lookup. None of these involve heap allocation after initial cache entry creation. The cost is deterministic and does not vary with session duration.

IndicatorCache.mqh

IndicatorCache.mqh implements the core CIndicatorCache class. The three methods below are the most important to understand: BuildKey(), AcquireHandle(), and ReleaseHandle().

BuildKey constructs the composite lookup string:

//+------------------------------------------------------------------+
//| BuildKey                                                         |
//| Purpose: Formulates a unique composite identity reference string |
//+------------------------------------------------------------------+
string CIndicatorCache::BuildKey(string symbol, ENUM_TIMEFRAMES tf,
                                 ENUM_INDICATOR_TYPE ind_type, string params)
  {
   return(symbol + "_" +
          EnumToString(tf) + "_" +
          IntegerToString((int)ind_type) + "_" +
          params);
  }

EnumToString(tf) produces PERIOD_H1 rather than a raw integer, making keys human-readable in journal output. The indicator type is cast to int before IntegerToString() rather than passed to EnumToString(), because the type component uses the enum's numeric value as the discriminator, not its label.

AcquireHandle performs the cache lookup and creates the handle on a miss:

//+------------------------------------------------------------------+
//| AcquireHandle                                                    |
//| Purpose: Requests handle reference; constructs new if uncached   |
//+------------------------------------------------------------------+
int CIndicatorCache::AcquireHandle(string symbol, ENUM_TIMEFRAMES tf,
                                   ENUM_INDICATOR_TYPE ind_type, string params)
  {
   string key   = BuildKey(symbol, tf, ind_type, params);
   int    index = FindEntry(key);

   if(index >= 0)
     {
      m_entries[index].m_ref_count++;
      m_stats.RecordHit();
      LogMessage("[HIT] " + key + " refs=" + IntegerToString(m_entries[index].m_ref_count));
      return(m_entries[index].m_handle);
     }

   //--- Cache miss validation checks
   if(m_count >= m_max_capacity)
     {
      LogMessage("[CACHE] Capacity limit reached (" + IntegerToString(m_max_capacity) + "). Cannot create: " + key);
      return(INVALID_HANDLE);
     }

   int handle = CreateHandle(symbol, tf, ind_type, params);
   if(handle == INVALID_HANDLE)
     {
      LogMessage("[ERROR] Handle creation failed for: " + key);
      return(INVALID_HANDLE);
     }

   //--- Commit new tracking record
   ArrayResize(m_entries, m_count + 1);
   m_entries[m_count].m_key            = key;
   m_entries[m_count].m_handle         = handle;
   m_entries[m_count].m_ref_count      = 1;
   m_entries[m_count].m_symbol         = symbol;
   m_entries[m_count].m_timeframe      = tf;
   m_entries[m_count].m_indicator_type = ind_type;
   m_entries[m_count].m_params         = params;
   m_entries[m_count].m_memory_est_kb  = CACHE_MEMORY_PER_HANDLE_KB;
   m_count++;

   m_stats.RecordMiss(CACHE_MEMORY_PER_HANDLE_KB);
   LogMessage("[CREATE] " + key);

   return(handle);
  }

On a hit, the existing handle is returned and the ref count incremented in a single branch — no allocation occurs. On a miss, the capacity ceiling is checked before any allocation attempt, so a full cache never silently overwrites an existing entry. The new entry is committed only after CreateHandle() confirms a valid handle, preventing partially-initialized entries from entering the array.

ReleaseHandle decrements and conditionally destroys:

//+-------------------------------------------------------------------------+
//| ReleaseHandle                                                           |
//| Purpose: Decrements tracking counters, releasing memory if unreferenced |
//+-------------------------------------------------------------------------+
void CIndicatorCache::ReleaseHandle(string symbol, ENUM_TIMEFRAMES tf,
                                    ENUM_INDICATOR_TYPE ind_type, string params)
  {
   string key   = BuildKey(symbol, tf, ind_type, params);
   int    index = FindEntry(key);

   if(index < 0)
     {
      LogMessage("[WARN] ReleaseHandle called for unknown key: " + key);
      return;
     }

   m_entries[index].m_ref_count--;
   LogMessage("[RELEASE] " + key + " refs=" + IntegerToString(m_entries[index].m_ref_count));

   if(m_entries[index].m_ref_count <= 0)
     {
      IndicatorRelease(m_entries[index].m_handle);
      m_stats.RecordRelease(m_entries[index].m_memory_est_kb);
      LogMessage("[DESTROY] " + key);
      RemoveEntry(index);
     }
  }

The release path calls IndicatorRelease() on the terminal handle only when the ref count reaches zero. RemoveEntry() then collapses the array by shifting all entries above the removed index one position down before resizing. Calling ReleaseHandle() on a key not present in the cache logs a [WARN] message and returns without a crash, making over-release safe to diagnose.

The factory switch inside CreateHandle maps each enum value to its MQL5 creation function. Below is the ATR and MA case, representative of the full ten-case switch:

//+------------------------------------------------------------------+
//| CreateHandle                                                     |
//| Purpose: Instantiates terminal objects using factory mechanics   |
//+------------------------------------------------------------------+
int CIndicatorCache::CreateHandle(string symbol, ENUM_TIMEFRAMES tf,
                                  ENUM_INDICATOR_TYPE ind_type, string params)
  {
   int handle = INVALID_HANDLE;

   switch((int)ind_type)
     {
      case INDT_ATR:
        {
         int period = (int)StringToInteger(params);
         handle = iATR(symbol, tf, period);
         break;
        }
      case INDT_MA:
        {
         string parts[];
         int    part_count = StringSplit(params, '_', parts);
         if(part_count >= 4)
           {
            int period  = (int)StringToInteger(parts[0]);
            int shift   = (int)StringToInteger(parts[1]);
            int method  = (int)StringToInteger(parts[2]);
            int applied = (int)StringToInteger(parts[3]);
            handle = iMA(symbol, tf, period, shift,
                         (ENUM_MA_METHOD)method,
                         (ENUM_APPLIED_PRICE)applied);
           }
         break;
        }
      // ... remaining cases follow the same pattern
      default:
         break;
     }

   return(handle);
  }

Single-parameter indicators like ATR and ADX receive their params string as a plain integer string. Multi-parameter indicators like MA and MACD use StringSplit() with an underscore delimiter to recover each value in the order they were serialized by the caller's helper functions.


Silent Leak Prevention

The terminal assigns a finite number of indicator handle slots per chart. If an EA creates handles in OnInit() but does not release them in OnDeinit(), the handle slots remain occupied. This can happen due to missing cleanup code or an error-driven stop. Over a session where the EA is reloaded multiple times during parameter optimization, for example, the orphaned handles accumulate. The terminal does not recycle them automatically between EA instances on the same chart.

The cache's FlushAll() method, called unconditionally in OnDeinit(), iterates every entry regardless of ref count and calls IndicatorRelease() on each handle. This single call guarantees a clean slate regardless of whether individual modules called ReleaseHandle() before the EA was stopped. It is the structural equivalent of a destructor that cannot be bypassed.

The FlushAll() method logs each released handle to the journal with the [FLUSH] prefix, providing an audit trail that makes it possible to verify post-session that no handles were silently abandoned.

LazyLoadingEA.mq5 demonstrates the cache in use. OnInit() pre-acquires all handles, OnTick() reads from the stored integers directly, and OnDeinit() flushes the cache unconditionally.

OnInit — handle pre-acquisition loop:

for(int s = 0; s < g_symbol_count; s++)
     {
      string sym = g_symbols[s];

      for(int t = 0; t < g_tf_count; t++)
        {
         ENUM_TIMEFRAMES tf = g_timeframes[t];

         string ma_params  = BuildMAParams(inp_ma_period, 0, (int)MODE_EMA, (int)PRICE_CLOSE);
         string rsi_params = BuildRSIParams(inp_rsi_period, (int)PRICE_CLOSE);

         //--- Populate collection array by executing initial cache lazy-allocations
         g_handles[g_handle_count]     = g_cache.AcquireHandle(sym, tf, INDT_ATR, IntegerToString(inp_atr_period));
         g_handles[g_handle_count + 1] = g_cache.AcquireHandle(sym, tf, INDT_MA,  ma_params);
         g_handles[g_handle_count + 2] = g_cache.AcquireHandle(sym, tf, INDT_RSI, rsi_params);
         g_handle_count += 3;
        }
     }

Each call to AcquireHandle() is the first request for that key, so every call is a cache miss that triggers handle creation and journal output. The returned handle integers are stored in g_handles[] for direct use in OnTick().

OnTick — direct buffer reads without cache interaction:

double buf[1];
   for(int i = 0; i < g_handle_count; i++)
     {
      if(g_handles[i] != INVALID_HANDLE)
        {
         CopyBuffer(g_handles[i], 0, 0, 1, buf);
        }
     }

   //--- Update dashboard with current cache statistics
   g_dashboard.Update(g_cache.GetActiveCount(), g_cache.GetStatistics());

OnTick() calls CopyBuffer() directly on the stored handle integers. It does not call AcquireHandle() again, so the hit counter remains at zero in this single-consumer pattern — consistent with the dashboard behavior described in the Dashboard Panel section.

OnDeinit — unconditional cleanup:

if(CheckPointer(g_cache) == POINTER_DYNAMIC)
     {
      g_cache.FlushAll();
      delete g_cache;
      g_cache = NULL;
     }

   ArrayFree(g_handles);
   g_handle_count = 0;

   if(CheckPointer(g_dashboard) == POINTER_DYNAMIC)
     {
      g_dashboard.Remove();
      delete g_dashboard;
      g_dashboard = NULL;
     }

FlushAll() releases every handle in the cache regardless of its ref count before delete is called on the cache object. CheckPointer() guards against double-free if OnDeinit() is somehow called after a previous partial cleanup. The dashboard's Remove() call deletes all chart objects before the pointer is freed.


Dashboard Panel Design

The dashboard renders four metrics as chart label objects in the top-left corner of the EA's chart. It updates on every call to Update(), which is called from OnTick(). The four displayed values are: active handle count, cache hit percentage, total cache miss count, and estimated memory usage in KB.

Memory estimation uses a fixed per-handle baseline of 48 KB for standard indicators on a 500-bar chart, multiplied by the active handle count. This is an approximation; actual terminal memory allocation for indicator buffers varies by indicator type, bar count, and buffer count. The figure serves as a relative indicator of resource consumption rather than a precise measurement.

In the single-consumer pre-acquisition pattern used by the demonstration EA, the cache hit rate displayed on the dashboard remains at 0.0% throughout the session. This is correct: all 30 handles are acquired once in OnInit() as cache misses, and OnTick() reads from the stored handle integers directly without calling AcquireHandle() again, so the hit counter never increments. The hit rate metric is most meaningful in a multi-consumer architecture where separate trading modules independently call AcquireHandle() for the same indicator on the same symbol and timeframe, producing cache hits after the first module's initial acquisition. The dashboard values shown in Figure 4 below illustrate this multi-consumer scenario.

DashboardPanel.mqh implements CDashboardPanel, which creates and updates five chart label objects on the EA's chart.

//+------------------------------------------------------------------+
//| Update                                                           |
//| Purpose: Pushes refreshed metrics onto the target chart context  |
//+------------------------------------------------------------------+
void CDashboardPanel::Update(int active_handles, CCacheStatistics *stats)
  {
   //--- Construct layout components dynamically on initial runtime loop
   if(!m_initialized)
     {
      CreateLabel(m_prefix + "TITLE",   m_y_offset);
      CreateLabel(m_prefix + "HANDLES", m_y_offset + m_line_spacing);
      CreateLabel(m_prefix + "HITRATE", m_y_offset + m_line_spacing * 2);
      CreateLabel(m_prefix + "MISSES",  m_y_offset + m_line_spacing * 3);
      CreateLabel(m_prefix + "MEMORY",  m_y_offset + m_line_spacing * 4);
      m_initialized = true;
     }

   //--- Bind raw statistics inputs to display formats
   SetLabelText(m_prefix + "TITLE",   "[ Indicator Cache ]");
   SetLabelText(m_prefix + "HANDLES", "Active Handles : " + IntegerToString(active_handles));
   SetLabelText(m_prefix + "HITRATE", "Cache Hit Rate : " + DoubleToString(stats.GetHitRate(), 1) + "%");
   SetLabelText(m_prefix + "MISSES",  "Cache Misses   : " + IntegerToString(stats.GetMissCount()));
   SetLabelText(m_prefix + "MEMORY",  "Est. Memory    : " + DoubleToString(stats.GetMemoryEstKB(), 0) + " KB");

   ChartRedraw(m_chart_id);
  }

Label objects are created only on the first call, controlled by the m_initialized flag. Every subsequent call updates the text of the existing objects rather than recreating them, avoiding ObjectCreate() overhead on every tick. CreateLabel() uses ObjectFind() to skip creation if an object with that name already exists, making Update() safe to call even if the dashboard was not cleanly removed between EA reloads.

//+--------------------------------------------------------------------+
//| Remove                                                             |
//| Purpose: Purges graphic resources sequentially from terminal space |
//+--------------------------------------------------------------------+
void CDashboardPanel::Remove(void)
  {
   string labels[] = {"TITLE", "HANDLES", "HITRATE", "MISSES", "MEMORY"};
   int size = ArraySize(labels);

   for(int i = 0; i < size; i++)
     {
      ObjectDelete(m_chart_id, m_prefix + labels[i]);
     }

   ChartRedraw(m_chart_id);
   m_initialized = false;
  }

Remove() iterates the fixed label name list and deletes each object. Resetting m_initialized to false means a subsequent Update() call after Remove() would recreate the labels cleanly, which is useful during EA parameter changes that trigger a deinit-reinit cycle without a full terminal restart.

On-chart dashboard tracking cache performance

Figure 4 - On-chart dashboard tracking cache performance


Limitations and Structural Constraints

Key collision risk: The composite key is a concatenated string using the enum's integer value as the type component. If two different ENUM_INDICATOR_TYPE values happen to produce the same integer string, or if a custom indicator's parameter string contains an underscore that coincidentally produces an identical key to a different indicator, a false cache hit would return the wrong handle. In practice this is avoided by keeping parameter strings distinct, but developers extending the enumeration must ensure uniqueness.

No dynamic resizing: The cache array is fixed at a maximum capacity set by inp_max_cache_size. Attempts to add entries beyond this limit are rejected and logged, and the caller receives INVALID_HANDLE. For a well-scoped EA, the capacity should be set to the product of symbol count, timeframe count, and indicator type count with a 20% margin.

Single-threaded only: MQL5 EAs and indicators execute on a single thread per instance. The cache contains no mutex or atomic operations. If MetaQuotes introduces concurrent execution contexts in future builds, the AcquireHandle() and ReleaseHandle() methods would require synchronization guards around the ref count modification and array access.

No handle validity re-check: The cache does not periodically verify that stored handles are still valid. If the terminal invalidates a handle internally during chart timeframe changes or symbol removal from Market Watch, the cache will continue to return the stale integer until the consumer reports a data copy failure. Implementing periodic validity sweeps would add O(N) overhead per sweep cycle.


Conclusion

Eager indicator initialization couples resource allocation to the EA's startup event rather than actual demand. This approach scales poorly in multi-timeframe systems with large symbol universes. As system complexity grows, the gap between initialized and utilized indicators widens. Lazy loading resolves this discrepancy by ensuring initialization costs track actual runtime usage.

The architecture relies on three primary technical components:

  • Independent Handle Sharing: The reference-counting model manages shared indicators without inter-module coordination. If two modules request identical indicator configurations, the cache returns the same handle. Each module releases it independently. The underlying terminal resource is created once and freed once.
  • Automated Resource Cleanup: The FlushAll() method guarantees complete resource disposal. If the EA stops due to an error, user action, or platform restart, the cache releases all terminal handles. Centralizing this ownership eliminates the need for separate cleanup code in every module, reducing the risk of silent handle leaks as the codebase expands.
  • Quantifiable Overhead: The architecture introduces predictable performance trade-offs. These consist of a string comparison per lookup, two integer operations per acquire-release cycle, and a small, fixed memory footprint per cache entry.

In large multi-timeframe applications, this minor runtime overhead is offset by the resulting reductions in startup latency, memory consumption, and code maintenance.


Programs used in the article:

#NameTypeDescription
1IndicatorEntry.mqhInclude FileSCacheEntry struct and ENUM_INDICATOR_TYPE enumeration with INDT_ prefixed members; holds handle, ref count, composite key, type, parameters, and memory estimate per cached indicator
2CacheStatistics.mqhInclude File
CCacheStatistics class tracking hit count, miss count, hit rate, and estimated memory usage across the session
3IndicatorCache.mqhInclude File
CIndicatorCache class implementing composite key generation, lazy handle creation via a ten-case factory switch, ref-counted acquisition and release, FlushAll() cleanup, and statistics delegation
4DashboardPanel.mqhInclude File
CDashboardPanel class rendering active handles, hit rate, miss count, and memory estimate as live chart label objects using the Consolas font
5LazyLoadingEA.mq5Demo EADemonstration EA pre-acquiring ATR, MA, and RSI handles for all configured symbols and timeframes in OnInit(), reading from stored handle integers in OnTick() without cache communication, logging creation events, updating the dashboard, and performing deterministic cleanup via FlushAll() in OnDeinit()
6Lazy_Loading_Indicator_Handles_in_MQL5.zipZip ArchiveZip archive containing all the attached files and their paths relative to the terminal's root folder.
Last comments | Go to discussion (1)
Stanislav Korotky
Stanislav Korotky | 29 Jun 2026 at 15:50
You don't need all this: the platform supports reference counting and resource cleanup (for indicator handles left unreferenced after deinit) right from the shelf, and behind the scenes.
Features of Custom Indicators Creation Features of Custom Indicators Creation
Creation of Custom Indicators in the MetaTrader trading system has a number of features.
MetaTrader 5 Machine Learning Blueprint (Part 18): Sequential Bootstrap, Corrected — Clone, Class Erasure, and the Comparison Toolkit MetaTrader 5 Machine Learning Blueprint (Part 18): Sequential Bootstrap, Corrected — Clone, Class Erasure, and the Comparison Toolkit
The article diagnoses two defects that neutralize sequential bootstrap during cross‑validation: type erasure of SequentiallyBootstrappedBaggingClassifier and a fold‑level shape mismatch from cloning full samples info sets. It retains the classifier's identity, adds find seq bagging to re‑inject fold‑sliced t1 in CalibratorCV.fit, and resets state per split. A new bootstrap_comparison module reports OOF and OOB metrics and memory, letting you verify that sequential sampling is applied correctly and quantify its impact.
Features of Experts Advisors Features of Experts Advisors
Creation of expert advisors in the MetaTrader trading system has a number of features.
Engineering a Self-Healing Expert Advisor in MQL5 (Part 3): Restart-Aware Breakeven and Trailing Systems Engineering a Self-Healing Expert Advisor in MQL5 (Part 3): Restart-Aware Breakeven and Trailing Systems
Building on Part 2, the implementation introduces restart-aware breakeven and trailing-stop systems for MetaTrader 5. The EA persists the state, such as breakeven activation, last trailing price, and virtual SL in SQLite, then restores them on startup. This preserves dynamic protection flow and prevents lost progress after terminal interruptions.