preview
The MQL5 Standard Library Explorer (Part 18): Building a Multi-Symbol Indicator Cache

The MQL5 Standard Library Explorer (Part 18): Building a Multi-Symbol Indicator Cache

MetaTrader 5 — Examples |
100 0
Clemence Benjamin
Clemence Benjamin

In Part 17 of the MQL5 Standard Library Explorer, we replaced duplicated multi-symbol variables with independently owned strategy nodes coordinated by a portfolio manager. That structure exposes the next practical problem. In multi-symbol Expert Advisors, indicator objects are frequently created and managed inside every strategy module: each consumer repeats the same handle creation, BarsCalculated() checks, Refresh() calls, buffer reads, error handling, and release logic. This is acceptable while requests remain unique, but when several nodes request the same indicator type, symbol, timeframe, and inputs, it becomes inefficient and error-prone. Duplicate calculations, repeated readiness code, and unclear resource ownership make safe shutdown and partial initialization difficult.

This article implements a shared indicator cache that centralizes creation, readiness checking, buffer refresh, lifetime ownership, and final release. A consumer describes the indicator it needs; for the supported MA and ATR objects, the cache builds a key from every parameter accepted by the corresponding builder, returns an existing entry on a hit or creates one on a miss, applies the BarsCalculated()+Refresh()+GetData() readiness boundary, and manages lifetime with reference counting and a Release(entry&) contract. Strategy nodes keep their independent signal and position state, but they acquire and return cache entries instead of managing indicators directly. The result is a predictable, reusable resource layer suitable for adding future signal, risk, reporting, and visualization modules.


Contents

  1. Why an Indicator Cache
  2. Cache Architecture
  3. Defining Indicator Identity for MA and ATR
  4. Building Cache Entries
  5. Creating Standard Library Indicators
  6. Acquiring and Reusing Entries
  7. Reading Ready Indicator Data
  8. Reference Counting and Release
  9. Connecting Cache-Backed Strategy Nodes
  10. Coordinating the Cached Portfolio
  11. Operating the Expert Advisor
  12. Testing and Verification
  13. Limitations
  14. Conclusion
  15. Key Lessons
  16. Attachments


Why an Indicator Cache

A multi-symbol Expert Advisor (EA) often creates indicator handles inside every strategy module. That approach works while every request is unique. It becomes wasteful and difficult to maintain when several consumers request the same indicator type, symbol, timeframe, and inputs. Each module then repeats creation checks, readiness checks, buffer access, error handling, and release logic.

A cache turns indicator access into a service. The consumer describes the indicator it needs. The cache converts that description into a complete key and searches its entry collection. A matching entry is reused; a missing entry is created. Consumers return their references during shutdown, and the final release removes the entry.

Without a cache With a cache
Every module creates and releases indicators directly The cache owns indicator creation and release
Matching requests can create duplicate resources Matching keys return the same cache entry
Readiness logic is repeated Every read passes through one readiness boundary
Ownership is distributed across consumers References identify active consumers

The objective is not merely to reduce the number of handles. We are building a predictable resource lifecycle that can support future signal, risk, reporting, and visualization modules.


Cache Architecture

The package contains four connected source modules. IndicatorCache.mqh defines the cache entry and cache service. CachedStrategyNode.mqh consumes cached indicators. IndicatorPortfolioManager.mqh owns the cache and the strategy-node collection. MultiSymbolIndicatorCacheEA.mq5 provides the terminal event handlers.

Multi-symbol indicator cache components and ownership boundaries

Fig. 1. Multi-symbol indicator cache components and ownership boundaries

Two CArrayObj collections appear in Fig. 1. The first owns strategy nodes. The second collection owns indicator cache entries. A cache entry owns one Standard Library CIndicator descendant. This layered ownership makes the shutdown order explicit: nodes return their references first, and the cache releases any remaining entries afterward.

Class Owns Main responsibility
CIndicatorCacheEntry One CIndicator-derived object Readiness, refreshed data, status, and reference metadata
CIndicatorCache CArrayObj of cache entries Key lookup, acquisition, hit/miss counting, release, and deletion
CCachedStrategyNode Three cache references Closed-bar crossover signal and Average True Range risk distance
CIndicatorPortfolioManager Cache and strategy-node collection Creation, event routing, diagnostics, and ordered shutdown


Defining Indicator Identity for MA and ATR

A cache can reuse an entry only if its identity includes every property that affects the output of the supported indicator. The symbol and timeframe are insufficient. Two moving averages on EURUSD H1 remain different when they use different periods, shifts, methods, or settings for the price field. In this implementation, the MA and ATR builders define complete identities for the parameters accepted by CiMA and CiATR; a future indicator requires its own parameter audit.

The moving-average key therefore contains:

  • indicator type,
  • symbol,
  • timeframe,
  • averaging period,
  • horizontal shift,
  • averaging method, and
  • the price field used by the indicator.
//+------------------------------------------------------------------+
//| Builds a complete identity key for a moving average              |
//+------------------------------------------------------------------+
string CIndicatorCache::MAKey(const string symbol,
                              const ENUM_TIMEFRAMES timeframe,
                              const int period,
                              const int shift,
                              const ENUM_MA_METHOD method,
                              const ENUM_APPLIED_PRICE applied) const
  {
//--- Encode all moving-average parameters into one unique string
   return(StringFormat("MA|%s|%d|%d|%d|%d|%d",symbol,(int)timeframe,
                       period,shift,(int)method,(int)applied));
  }

Average True Range (ATR) needs only the indicator type, symbol, timeframe, and period. The string keys are readable in the chart summary, which helps us verify whether two requests should match. A production framework could replace strings with structured keys, but the identity requirements would remain the same.

The key-building function is the cache's identity boundary. Its arguments arrive in typed form, but StringFormat converts them into one deterministic lookup value. Including the enumeration values as integers prevents display names from influencing equality. If we later add another moving-average parameter, we must include it in both MAKey and AcquireMA. Otherwise, two distinct calculations may map to the same entry.


Building Cache Entries

CIndicatorCacheEntry derives from CObject so that CArrayObj can own it. The entry combines an indicator pointer with the information needed to manage it.

//+------------------------------------------------------------------+
//| Owns one Standard Library indicator and its cache metadata       |
//+------------------------------------------------------------------+
class CIndicatorCacheEntry : public CObject
  {
private:
   string                    m_key;
   string                    m_symbol;
   ENUM_TIMEFRAMES           m_timeframe;
   ENUM_CACHE_INDICATOR_TYPE m_type;
   CIndicator               *m_indicator;
   int                       m_references;
   int                       m_requests;
   string                    m_status;

public:
                             CIndicatorCacheEntry(void);
                            ~CIndicatorCacheEntry(void);
   bool                      CreateMA(const string key,
                                     const string symbol,
                                     const ENUM_TIMEFRAMES timeframe,
                                     const int period,
                                     const int shift,
                                     const ENUM_MA_METHOD method,
                                     const ENUM_APPLIED_PRICE applied);
   bool                      CreateATR(const string key,
                                      const string symbol,
                                      const ENUM_TIMEFRAMES timeframe,
                                      const int period);
   void                      AddReference(void);
   int                       RemoveReference(void);
   bool                      Value(const int shift,double &value);
   bool                      Ready(const int minimum_bars);
   void                      Release(void);
   string                    Key(void) const { return(m_key); }
   string                    Symbol(void) const { return(m_symbol); }
   ENUM_TIMEFRAMES           Timeframe(void) const { return(m_timeframe); }
   ENUM_CACHE_INDICATOR_TYPE IndicatorType(void) const { return(m_type); }
   int                       References(void) const { return(m_references); }
   int                       Requests(void) const { return(m_requests); }
   int                       Handle(void) const;
   string                    Status(void) const { return(m_status); }
   string                    Summary(void) const;
  };

m_references records how many consumers currently hold the entry. m_requests records cumulative acquisitions, including cache hits. The distinction lets the chart summary show both current ownership and historical reuse.

The entry stores a CIndicator pointer because CIndicator is the common Standard Library base for CiMA, CiATR, and many other technical indicator classes. The cache can therefore use the same Handle, BarsCalculated, Refresh, GetData, and FullRelease interface after the specialized object has been created.

This class separates resource ownership from resource use. The strategy node never needs to know whether the stored pointer addresses CiMA or CiATR; it works through the common entry interface. At the same time, m_references answers a lifetime question, while m_requests answers a diagnostic question. Keeping those counters separate lets you distinguish an entry that has many active consumers from one that has simply been requested many times during its history.


Creating Standard Library Indicators

The first supported type is CiMA. CreateMA allocates the specialized object, calls its Create method, and transfers it to the base pointer only after creation succeeds.

//+------------------------------------------------------------------+
//| Creates and owns a moving-average indicator                      |
//+------------------------------------------------------------------+
bool CIndicatorCacheEntry::CreateMA(const string key,
                                    const string symbol,
                                    const ENUM_TIMEFRAMES timeframe,
                                    const int period,
                                    const int shift,
                                    const ENUM_MA_METHOD method,
                                    const ENUM_APPLIED_PRICE applied)
  {
//--- Refuse to overwrite an existing entry or accept invalid arguments
   if(m_indicator!=NULL || key=="" || symbol=="" || period<1)
      return(false);

//--- Allocate a new moving-average indicator object
   CiMA *indicator=new CiMA;
   if(indicator==NULL)
      return(false);

//--- Attach the indicator to the requested symbol and timeframe
   if(!indicator.Create(symbol,timeframe,period,shift,method,(int)applied))
     {
      delete indicator;
      return(false);
     }

//--- Adopt the indicator and record its initial reference count
   m_key=key;
   m_symbol=symbol;
   m_timeframe=timeframe;
   m_type=CACHE_INDICATOR_MA;
   m_indicator=indicator;
   m_references=1;
   m_requests=1;
   m_status="CREATED";
   return(true);
  }

The second type is CiATR. Its creation signature needs the symbol, timeframe, and averaging period. Both paths delete an unowned object after creation failure. Once m_indicator receives the pointer, the cache entry owns it.

Release calls FullRelease, deletes the Standard Library object, sets the pointer to NULL, resets the reference count, and changes the status to RELEASED. The cache-entry destructor calls Release, so CArrayObj deletion reaches the underlying terminal indicator resource.

CreateMA behaves like a small transaction. Validation and indicator creation occur before m_indicator receives the pointer. Therefore, an invalid argument or failed Create call leaves the cache entry empty and safe to destroy. Only after the terminal accepts the indicator configuration does the function commit the key, symbol, timeframe, type, and counters. This ordering prevents a half-configured entry from appearing valid to a later cache lookup.


Acquiring and Reusing Entries

AcquireMA and AcquireATR follow the same workflow. They build a key, search the entry collection, return a hit when the key already exists, or create a new entry after a miss.

Cache acquisition, reuse, and release workflow

Fig. 2. Cache acquisition, reuse, and release workflow

//+------------------------------------------------------------------+
//| Returns a reused or newly created moving-average entry           |
//+------------------------------------------------------------------+
CIndicatorCacheEntry *CIndicatorCache::AcquireMA(const string symbol,
                                                 const ENUM_TIMEFRAMES timeframe,
                                                 const int period,
                                                 const int shift,
                                                 const ENUM_MA_METHOD method,
                                                 const ENUM_APPLIED_PRICE applied)
  {
//--- Reuse an existing entry when its identity is already cached
   const string key=MAKey(symbol,timeframe,period,shift,method,applied);
   const int found=FindIndex(key);
   if(found>=0)
     {
      CIndicatorCacheEntry *entry=(CIndicatorCacheEntry*)m_entries.At(found);
      if(entry!=NULL)
        {
         //--- Increment the reference count and record a cache hit
         entry.AddReference();
         m_hits++;
        }
      return(entry);
     }

//--- Allocate a new entry when no matching cached entry exists
   CIndicatorCacheEntry *entry=new CIndicatorCacheEntry;
   if(entry==NULL)
      return(NULL);

//--- Create and store the moving-average entry in the cache
   if(!entry.CreateMA(key,symbol,timeframe,period,shift,method,applied))
     {
      delete entry;
      return(NULL);
     }
   if(!m_entries.Add(entry))
     {
      delete entry;
      return(NULL);
     }

//--- Record the cache miss and return the newly created entry
   m_misses++;
   return(entry);
  }

A hit returns the existing pointer and increments both its active reference count and cumulative request count. A miss allocates CIndicatorCacheEntry, creates the requested Standard Library indicator, and adds the entry to the owning collection. If creation or Add fails, the unowned entry is deleted immediately.

The EA calls ProbeMAReuse after the first strategy node is created. The probe requests the same fast moving average and immediately releases it. It does not create another strategy node or retain an extra reference. Its purpose is to make the hit path visible in the initial cache diagnostics.

The acquisition path also defines when ownership changes. Finding a key is not enough: the retrieved pointer must be valid before AddReference records another consumer. On a miss, the new entry remains local until both CreateMA and CArrayObj::Add succeed. The cache increments m_misses only after that transfer. Consequently, the counters describe usable cache results rather than allocation attempts that were rolled back.


Reading Ready Indicator Data

A valid indicator object does not guarantee that its buffers already contain the requested historical index. CIndicatorCacheEntry::Ready checks the handle, verifies the calculated bar count, and refreshes the indicator data before any read.

//+------------------------------------------------------------------+
//| Checks calculation readiness and refreshes recent buffer data    |
//+------------------------------------------------------------------+
bool CIndicatorCacheEntry::Ready(const int minimum_bars)
  {
//--- Reject entries that have no valid indicator handle
   if(m_indicator==NULL || m_indicator.Handle()==INVALID_HANDLE)
     {
      m_status="INVALID HANDLE";
      return(false);
     }

//--- Wait until the indicator has calculated enough bars
   if(m_indicator.BarsCalculated()<minimum_bars)
     {
      m_status="WAITING FOR BARS";
      return(false);
     }

//--- Refresh the indicator buffers and mark the entry as ready
   m_indicator.Refresh();
   m_status="READY";
   return(true);
  }

The documentation for CIndicator::GetData states that Refresh should be called before recent data are accessed. The current Ready implementation invokes Refresh but does not test a separate success result. Its READY status therefore means that the handle is valid, the required number of bars is calculated, and a refresh was requested; it does not independently confirm refresh success.

Value adds the data-level checks that follow readiness. It initializes the output defensively, rejects negative shifts, reads buffer zero, and refuses EMPTY_VALUE or a number that fails MathIsValidNumber. This extract makes the read contract visible rather than asking you to infer it from the class description.

//+------------------------------------------------------------------+
//| Reads one value from buffer zero after readiness validation      |
//+------------------------------------------------------------------+
bool CIndicatorCacheEntry::Value(const int shift,double &value)
  {
//--- Initialize the output and reject out-of-range shifts
   value=EMPTY_VALUE;
   if(shift<0 || !Ready(shift+1))
      return(false);

//--- Read the requested value from buffer zero of the indicator
   value=m_indicator.GetData(0,shift);
   if(value==EMPTY_VALUE || !MathIsValidNumber(value))
     {
      m_status="DATA UNAVAILABLE";
      return(false);
     }
   return(true);
  }

The strategy reads shifts 1 and 2 rather than shift 0. These indices represent completed bars after a new bar has opened. The node still maintains its independent completed-bar gate, so the timer cannot reevaluate the same crossover repeatedly.

Ready reports two conditions explicitly. WAITING FOR BARS tells the node to postpone evaluation while history is still being calculated, and INVALID HANDLE identifies a broken resource. Because Value calls this gate before GetData, every strategy receives the same validation behavior. A production extension should add an explicit refresh-result check if the selected Standard Library API exposes a result that can be tested reliably.


Reference Counting and Release

A consumer must not delete a cache entry directly. It returns the pointer to CIndicatorCache::Release. The cache locates that pointer in its own collection, decrements its reference count, clears the caller's pointer, and deletes the collection element only when no consumer remains. This contract protects the implemented node paths, but it cannot prevent another consumer from losing a pointer, failing to release it, or attempting an unbalanced release.

//+------------------------------------------------------------------+
//| Releases one reference and deletes the final unreferenced entry  |
//+------------------------------------------------------------------+
bool CIndicatorCache::Release(CIndicatorCacheEntry *&entry)
  {
//--- Treat a null pointer as an already-released entry
   if(entry==NULL)
      return(true);

//--- Locate the entry within the collection
   for(int i=0;i<m_entries.Total();i++)
     {
      if(m_entries.At(i)!=entry)
         continue;

      //--- Remove the reference and delete the entry when unused
      const int remaining=entry.RemoveReference();
      entry=NULL;
      if(remaining==0)
         return(m_entries.Delete(i));
      return(true);
     }

//--- The entry was not found in the collection
   entry=NULL;
   return(false);
  }

Because m_entries uses FreeMode(true), Delete destroys the cache entry. Its destructor then calls FullRelease on the owned CIndicator object. This creates a complete chain:

  1. The strategy node returns its reference.
  2. The cache decrements the reference count.
  3. The final release removes the entry from CArrayObj.
  4. The entry destructor releases and deletes the indicator object.

Shutdown order is essential. Strategy nodes must be destroyed before the cache. Otherwise, a node could attempt to return a pointer to a cache that no longer owns the entry.

Setting the caller's entry pointer to NULL is part of the release contract; it is not cosmetic cleanup. It prevents the consumer from retaining an address after the final CArrayObj::Delete may have destroyed the object. Returning true for an already-null pointer also makes repeated cleanup safe during partial initialization and normal destruction. The manager can therefore unwind several failure paths without needing a separate flag for every acquired indicator.


Connecting Cache-Backed Strategy Nodes

Each CCachedStrategyNode receives a pointer to the shared cache during initialization. It acquires a fast exponential moving average (EMA), a slow EMA, and ATR. The node stores entry pointers rather than creating indicators itself.

//+------------------------------------------------------------------+
//| Acquires three indicators and initializes the symbol node        |
//+------------------------------------------------------------------+
bool CCachedStrategyNode::Initialize(CIndicatorCache *cache,
                                     const string symbol,
                                     const ENUM_TIMEFRAMES timeframe,
                                     const ulong magic,
                                     const int fast_period,
                                     const int slow_period,
                                     const int atr_period,
                                     const bool allow_trading,
                                     const double risk_percent,
                                     const double atr_multiplier,
                                     const int max_spread)
  {
//--- Reject invalid arguments or a missing cache reference
   if(cache==NULL || symbol=="" || magic==0 || fast_period<1 ||
      slow_period<=fast_period || atr_period<1 || risk_percent<=0.0 ||
      atr_multiplier<=0.0 || max_spread<0)
      return(false);

//--- Make sure the symbol is visible in the Market Watch
   if(!SymbolSelect(symbol,true))
      return(false);

//--- Store the node configuration
   m_cache=cache;
   m_symbol=symbol;
   m_timeframe=timeframe;
   m_magic=magic;
   m_allow_trading=allow_trading;
   m_risk_percent=risk_percent;
   m_atr_multiplier=atr_multiplier;
   m_max_spread=max_spread;

//--- Acquire the fast EMA from the shared indicator cache
   m_fast_ma=m_cache.AcquireMA(symbol,timeframe,fast_period,0,
                               MODE_EMA,PRICE_CLOSE);
   if(m_fast_ma==NULL)
     {
      Shutdown();
      return(false);
     }

//--- Acquire the slow EMA from the shared indicator cache
   m_slow_ma=m_cache.AcquireMA(symbol,timeframe,slow_period,0,
                               MODE_EMA,PRICE_CLOSE);
   if(m_slow_ma==NULL)
     {
      Shutdown();
      return(false);
     }

//--- Acquire the ATR used for stop distance from the shared cache
   m_atr=m_cache.AcquireATR(symbol,timeframe,atr_period);
   if(m_atr==NULL)
     {
      Shutdown();
      return(false);
     }

//--- Configure the trade object with the node magic and deviation
   m_trade.SetExpertMagicNumber(m_magic);
   m_trade.SetDeviationInPoints(20);

//--- Select a filling type supported by the symbol
   if(!m_trade.SetTypeFillingBySymbol(m_symbol))
     {
      Shutdown();
      return(false);
     }

//--- Store the current bar time and mark the node ready
   m_last_bar=iTime(m_symbol,m_timeframe,0);
   m_initialized=true;
   m_enabled=true;
   m_status="READY";
   return(true);
  }

Initialization stops and returns every acquired reference if a later acquisition fails. This prevents partial initialization from leaving an entry with a reference count that no active node owns.

EvaluateSignal reads the two completed values from both moving averages and one completed ATR value. A bullish crossover occurs when the fast EMA is above the slow EMA at shift 1 and was not above it at shift 2. The bearish condition reverses those comparisons. ATR defines the stop distance when trading is explicitly enabled.

Shutdown disables the node and returns fast MA, slow MA, and ATR references exactly once. Release sets each caller pointer to NULL, so a later destructor call remains safe.

Initialization demonstrates dependency ordering inside one node. The cache address and symbol configuration are stored first, then the fast EMA, slow EMA, and ATR references are acquired in sequence. Any failure calls Shutdown, which returns the references already obtained. Trading settings are applied only after all three resources exist. When Initialize returns true, the manager can rely on a complete node rather than testing each dependency again during every timer cycle.


Coordinating the Cached Portfolio

CIndicatorPortfolioManager owns the cache and a CArrayObj of strategy nodes. AddNode creates one node per symbol-timeframe identity and passes the same cache address to every successful node.

//+------------------------------------------------------------------+
//| Creates one node and transfers its ownership to CArrayObj        |
//+------------------------------------------------------------------+
bool CIndicatorPortfolioManager::AddNode(const string symbol,
                                         const ENUM_TIMEFRAMES timeframe,
                                         const ulong magic,
                                         const int fast_period,
                                         const int slow_period,
                                         const int atr_period,
                                         const bool allow_trading,
                                         const double risk_percent,
                                         const double atr_multiplier,
                                         const int max_spread)
  {
//--- Reject duplicate nodes for the same symbol and timeframe
   if(FindNode(symbol,timeframe)!=NULL)
     {
      Print("Duplicate cached node rejected: ",symbol);
      return(false);
     }

//--- Allocate a new cached strategy node
   CCachedStrategyNode *node=new CCachedStrategyNode;
   if(node==NULL)
      return(false);

//--- Initialize the node against the shared indicator cache
   if(!node.Initialize(&m_cache,symbol,timeframe,magic,fast_period,
                       slow_period,atr_period,allow_trading,risk_percent,
                       atr_multiplier,max_spread))
     {
      delete node;
      return(false);
     }

//--- Transfer the node ownership to the collection
   if(!m_nodes.Add(node))
     {
      delete node;
      return(false);
     }
   return(true);
  }

Deleting a partially initialized node invokes its destructor, which returns any cache entries it already acquired. After Add succeeds, the node collection owns the object.

ProcessAll routes each timer cycle to every node and records isolated failures. Summary combines portfolio counters, cache entries, handles, references, requests, hit/miss totals, and symbol-level signals. Shutdown clears nodes first and the cache second.

AddNode creates a second ownership boundary around that initialized node. Before m_nodes.Add succeeds, the local pointer belongs to the function and must be deleted after failure. After insertion, CArrayObj owns it through FreeMode(true). This rule complements the cache's reference ownership: deleting an unaccepted node triggers its destructor and returns any indicator references, while deleting an accepted node through the collection performs the same cleanup during portfolio shutdown.


Operating the Expert Advisor

Extract MQL5.zip into the terminal's Data Folder and merge its MQL5 folder with the existing MQL5 folder. MultiSymbolIndicatorCacheEA.mq5 is installed under MQL5\Experts\StandardLibraryExplorer\Part18\. The header files (IndicatorPortfolioManager.mqh, CachedStrategyNode.mqh, and IndicatorCache.mqh) are installed under MQL5\Include\StandardLibraryExplorer\Part18\. The EA imports the manager through <StandardLibraryExplorer\Part18\IndicatorPortfolioManager.mqh>. Compile the EA in MetaEditor and attach it to any chart. The program selects and manages the symbols listed in InpSymbols.

Input Default Purpose
InpSymbols EURUSD,GBPUSD,USDJPY,AUDUSD Comma-separated managed symbols
InpTimeframe PERIOD_H1 Timeframe included in every cache key
InpFastMAPeriod 12 Fast EMA averaging period
InpSlowMAPeriod 26 Slow EMA averaging period
InpATRPeriod 14 ATR averaging period
InpATRMultiplier 2.00 ATR multiple used for the stop distance
InpTimerSeconds 1 Portfolio processing interval
InpRiskPercentPerNode 0.25 Equity risk assigned to a new node position
InpBaseMagic 2026081800 Base for deterministic symbol ownership
InpMaxSpreadPoints 30 Maximum spread permitted for entry
InpAllowTrading false Explicit trade-execution permission

Trading remains disabled by default. Monitoring mode still creates and refreshes the cache, processes completed bars, detects EMA crossovers, stores ATR values, counts hits and misses, and reports entry status. This makes resource behavior observable before execution is permitted.

Cache diagnostics, node states, and open multi-symbol positions in the Strategy Tester

Fig. 3. Cache diagnostics, node states, and open multi-symbol positions in the Strategy Tester

Figure 3 captures the cache and its consumers during a trading-enabled test. The summary reports 12 entries for four symbols because every node requests a fast exponential moving average (EMA), a slow EMA, and an Average True Range (ATR) object. Each visible entry is READY and has one active reference. The first symbol's fast-MA entry records two cumulative requests when ProbeMAReuse has run: one request from the node and one temporary probe request. The probe releases its temporary reference, so the active count returns to one. This snapshot is consistent with one retained entry per displayed key, but it does not prove that duplication is impossible in every failure, restart, or reinitialization path.

The node summary confirms that the shared service does not merge state across symbols. EURUSD, GBPUSD, USDJPY, and AUDUSD retain independent ATR values, signals, position states, and failure counters. Three nodes hold buy positions while USDJPY remains flat. The Trade tab confirms that those positions have different volumes and stop distances. This is consistent with per-symbol ATR conditions and risk-based volume calculation rather than one fixed order template applied to the entire portfolio.

The displayed floating loss is only the state of the open positions at the captured moment. It is not a performance conclusion. The screenshot shows that several nodes were consuming READY cache entries while maintaining different trading states at that moment. Lifecycle invariants still require targeted acquisition, release, failure, and shutdown tests.


Testing and Verification

I tested the EA on EURUSD H1 from January 1 to December 31, 2024, using the 1-minute OHLC modeling method. The portfolio contained EURUSD, GBPUSD, USDJPY, and AUDUSD. Trading was enabled, the fast and slow EMA periods were 12 and 26, the ATR period was 14, and the ATR multiplier was 2.0. The journal confirms successful initialization, synchronization of all four histories, market-order execution, and server-side stop-loss and take-profit exits.

The table below is a verification plan, not a completed-results matrix. The recorded run supports initialization and trade-flow observations. Reuse, failed acquisition, unready history, final release, and ordered shutdown require their listed targeted procedures and should not be treated as proven by the trade journal alone.

Scenario Procedure Expected evidence
Initial misses Start with one valid symbol Three entries and three misses for fast MA, slow MA, and ATR
Reuse probe Allow OnInit() to request the first fast MA again One hit; the retained reference count returns to one
Several symbols Use the default portfolio Three distinct keys per symbol
Unready history Add a newly selected symbol before synchronization completes WAITING FOR BARS or INDICATORS NOT READY without invalid access
Completed-bar gate Observe many timer cycles inside one H1 bar Each node evaluates the crossover once
Partial initialization Force a later indicator acquisition to fail Earlier references are returned by the node destructor
Final release Remove or shut down the final consumer Reference reaches zero and the entry is deleted
Ordered shutdown Remove the EA Nodes release references before remaining cache entries are cleared
Trading disabled Keep InpAllowTrading false Indicators and signals update without orders

Recorded Tester Evidence

The following extract keeps the decisive events in chronological order. It begins with cache-backed portfolio initialization, then shows a long EURUSD order, its stop-loss exit, short EURUSD and GBPUSD orders, two take-profit exits, and later long orders across three symbols.

2024.01.01 00:00:00   Cached portfolio initialized with 4 nodes. Trading=ENABLED
2024.01.04 10:00:00   CTrade::OrderSend: market buy 0.12 EURUSD sl: 1.09479 tp: 1.09999 [done at 1.09687]
2024.01.04 11:59:58   stop loss triggered #2 buy 0.12 EURUSD 1.09687 sl: 1.09479 tp: 1.09999 [#3 sell 0.12 EURUSD at 1.09479]
2024.01.05 07:00:00   CTrade::OrderSend: market sell 0.15 EURUSD sl: 1.09423 tp: 1.09033 [done at 1.09267]
2024.01.05 09:00:00   CTrade::OrderSend: market sell 0.11 GBPUSD sl: 1.26833 tp: 1.26274 [done at 1.26609]
2024.01.05 13:59:58   take profit triggered #4 sell 0.15 EURUSD 1.09267 sl: 1.09423 tp: 1.09033 [#6 buy 0.15 EURUSD at 1.09033]
2024.01.05 13:59:58   take profit triggered #5 sell 0.11 GBPUSD 1.26609 sl: 1.26833 tp: 1.26274 [#7 buy 0.11 GBPUSD at 1.26274]
2024.01.05 16:00:00   CTrade::OrderSend: market buy 0.05 GBPUSD sl: 1.27072 tp: 1.28212 [done at 1.27528]
2024.01.05 17:00:00   CTrade::OrderSend: market buy 0.06 EURUSD sl: 1.09175 tp: 1.10165 [done at 1.09571]
2024.01.05 20:00:00   CTrade::OrderSend: market buy 0.06 AUDUSD sl: 0.66769 tp: 0.67726 [done at 0.67152]

The EURUSD orders use 0.12, 0.15, and 0.06 lots. These differences are consistent with the implemented ATR stop calculation and risk budget, but the journal alone cannot isolate their cause because equity, tick value, volume steps, symbol limits, and rounding also affect the final volume. The multi-symbol orders confirm that the manager processed several configured symbols during this test. Independence from host-chart ticks follows from the timer-based event path in the code, not from the trade log by itself.

This run confirms initialization and selected order-flow behavior under the stated period, symbols, inputs, and modeling method. It does not by itself verify every cache lifecycle path, profitability, or robustness. Before using the architecture beyond this lesson, record the account margin mode, spread, commission, slippage assumptions, and data quality. Repeat the test across out-of-sample periods and broker feeds. Multi-symbol tests also require synchronized history for every managed symbol.


Limitations

The cache supports CiMA and CiATR only. Adding CiRSI, CiBands, or a multi-buffer indicator requires a new key builder and creation method. The common Value method reads buffer zero; indicators with several meaningful buffers need an explicit buffer parameter or a specialized entry interface.

FindIndex performs a linear search through CArrayObj. This is clear and adequate for a small portfolio, but lookup cost grows with the number of unique indicator configurations. A later implementation can use sorted entries, comparison methods, or a separate index without changing consumer ownership.

Reference counting is manual. Every successful acquisition must have one matching release. The node destructor protects the implemented paths, but future consumers must follow the same contract. Release searches for the pointer in the cache collection, but it cannot prevent a consumer from losing its pointer or balance ownership across code that does not follow the contract. Forced Clear during shutdown releases remaining owned entries, yet that fallback does not make an unbalanced reference history correct. Reference counting also does not resolve cyclic ownership; this architecture avoids cycles by ensuring entries have no knowledge of consumers.

The EMA crossover is an educational consumer of the cache, not evidence of a trading advantage. The fixed 1.50 reward-to-risk ratio and the per-node allocation are deliberately simple. The recorded run demonstrates order flow and resource coordination, but it does not establish robustness. Aggregate exposure control remains the subject of the planned reusable risk-management installment.


Conclusion

We extended the portfolio manager with a centralized indicator service whose resource behavior can be inspected and tested. For the supported CiMA and CiATR configurations, the cache uses deterministic keys that encode the parameters accepted by their builders. It stores one CArrayObj-owned CIndicatorCacheEntry per successfully materialized key and exposes a small contract: Acquire returns a reused or newly created entry; Ready and Value apply the BarsCalculated()+Refresh()+GetData() read boundary; and Release(entry&) decrements active ownership, clears the caller's pointer, and deletes the entry when its reference count reaches zero. Hits, successful misses, cumulative requests, active references, and readiness states make the demonstrated paths observable. Correct shutdown still depends on consumers following the contract and on destroying nodes before clearing the cache.

As a consequence, the implemented strategy nodes no longer create, refresh, or delete terminal indicators directly. They consume shared entries while preserving their per-symbol state independently. The design can be extended through additional indicator-specific keys, explicit buffer selection, stronger refresh diagnostics, protected ownership wrappers, or alternative lookup indexes. Within the demonstrated paths, it centralizes duplicated resource work and makes ownership responsibilities explicit without claiming that one recorded run proves every lifecycle invariant.


Key Lessons

This article shows what the MQL5 Standard Library gives you when its classes are combined deliberately: reusable indicator objects, a common technical-indicator interface, an owning object collection, and predictable class-based cleanup. The lessons below connect each Library facility to the programming concept you learn and to its practical value when you build a multi-symbol trading system.

Standard Library focus Programming concept learned Practical trading-system benefit
CIndicator: a common indicator abstraction Polymorphism: CIndicator gives CiMA and CiATR a common readiness, refresh, and data-access surface while their creation parameters remain type-specific. You can expand the EA with another CIndicator-derived study without rewriting every strategy node, making new trading ideas faster to integrate and easier to compare.
CiMA and CiATR: typed indicator objects Resource identity: every parameter that changes a calculation must participate in the key used to decide whether an object can be shared. A fast EMA, slow EMA, or ATR request cannot silently receive another configuration, so each symbol is analyzed with the indicator settings you intended.
CObject: a storable polymorphic base Encapsulation: a CObject-derived wrapper combines the indicator pointer with identity, diagnostics, reference counts, and lifetime rules. The strategy receives a controlled indicator service instead of handling fragile terminal resources directly, reducing resource-management faults during monitoring or trading.
CArrayObj: collection ownership Collection ownership: CArrayObj stores cache entries and FreeMode(true) makes deletion follow removal from the owning collection. One cache controls the lifetime of indicator resources across all symbols, reducing leaks and double deletion when the EA initializes partially or shuts down.
CIndicator data-access methods A validation boundary: BarsCalculated, Refresh, and GetData are coordinated behind Ready and Value rather than scattered across consumers. A node postpones its signal when its indicator data are not ready instead of trading from an invalid or incomplete buffer value.
CIndicator::FullRelease and object destruction Symmetric lifetime management: every successful Acquire requires a matching Release, and the final release performs terminal and object cleanup. Several symbol nodes can share a calculation without one node prematurely destroying an indicator that another node still needs.
CArrayObj::Clear and dependency order Reverse dependency cleanup: consumer nodes are destroyed before the shared cache that serves them. EA removal and failed initialization follow a predictable shutdown path rather than leaving nodes with references to an already destroyed service.
Object metadata around Library classes Observability: wrapper metadata exposes keys, hits, successful misses, cumulative requests, active references, and readiness states. You can diagnose whether nodes are reusing the intended indicators and whether a symbol is waiting for data before you interpret its signal or enable trading.
Composition of collections and indicator classes Separation of shared services and local state: nodes share indicator infrastructure but retain their own symbol, bar gate, signal, ATR value, position view, and failures. A data problem or neutral signal on one market does not need to overwrite the analytical state of the other managed markets.
Extending rather than replacing the Library Extension by composition: the cache adds application-level identity and sharing while leaving indicator calculation to the tested Standard Library classes. You can improve lookup, ownership guards, refresh diagnostics, or multi-buffer access without rebuilding MA and ATR calculations from scratch.


Attachments

I prepared MQL5.zip as a merge-ready archive. Extract its MQL5 folder into the terminal's Data Folder. The archive preserves the separation between the host EA and the reusable headers:

Component Type Installation path Responsibility
MultiSymbolIndicatorCacheEA.mq5 Expert Advisor MQL5\Experts\StandardLibraryExplorer\Part18\ Parses symbols and hosts timer and trade-transaction events
IndicatorPortfolioManager.mqh Include class MQL5\Include\StandardLibraryExplorer\Part18\ Owns the cache and cache-backed node collection
CachedStrategyNode.mqh Include class MQL5\Include\StandardLibraryExplorer\Part18\ Consumes cached fast MA, slow MA, and ATR entries
IndicatorCache.mqh Include class MQL5\Include\StandardLibraryExplorer\Part18\ Creates, reuses, refreshes, reference-counts, and releases indicators
Attached files |
MQL5.zip (13.97 KB)
Controlling MetaTrader 5 from Telegram with Hermes and MCP Controlling MetaTrader 5 from Telegram with Hermes and MCP
Hermes Agent links a private Telegram bot to the MetaTrader 5 MCP interface, so natural-language requests call terminal tools under controlled permissions. The article shows how to install Hermes on Windows, configure an AI service and the Telegram gateway, authorize users, and add the MCP settings generated by the terminal. Readers learn to run read‑only checks, perform bounded MQL5 edits and compilation, launch Strategy Tester runs, inspect market and account data, and enable trading tools deliberately.
Neural Networks in Trading: A Spike-Semantic Approach to Spatiotemporal Identification (Conclusion) Neural Networks in Trading: A Spike-Semantic Approach to Spatiotemporal Identification (Conclusion)
In our interpretation, S3CE-Net skillfully translates the market into the language of events and captures early impulses that traditional indicators simply average out. STFS ensures robust training — the model sees the data from different perspectives and does not overfit to local anomalies. SSAM blocks and the OpenCL implementation deliver practical speed and accuracy, while the separation between training and deployment modes saves resources in production.
Features of Experts Advisors Features of Experts Advisors
Creation of expert advisors in the MetaTrader trading system has a number of features.
The Hypothesis of Randomness: Searching for Hidden Patterns in Price Series The Hypothesis of Randomness: Searching for Hidden Patterns in Price Series
The article describes a test of the hypothesis of randomness for quotes based on a chi-square statistic derived from the frequencies of overlapping s-chains. The article shows how to define discrete states and compare observed and expected frequencies in order to detect Markov memory in price increments. This approach helps distinguish structural dependence from noise and formalize the testing of trading hypotheses.