preview
Institutional-Grade Multi-Currency Portfolio Engine in MQL5 (Part 2): Instrument Universe Management and Symbol Metadata

Institutional-Grade Multi-Currency Portfolio Engine in MQL5 (Part 2): Instrument Universe Management and Symbol Metadata

MetaTrader 5 — Trading |
104 0
Ushana Kevin Iorkumbul
Ushana Kevin Iorkumbul

Introduction

A single-symbol Expert Advisor can afford to hardcode assumptions about its instrument. It knows the pip value, the contract size, the minimum lot, and the tick size because it was written and tested against one specific symbol. When that EA is deployed inside a multi-currency portfolio framework, those assumptions break immediately.

Consider a portfolio holding EURUSD, XAUUSD (spot gold), US30 (a CFD index), and USDJPY simultaneously. The contract size on EURUSD is 100,000 units of base currency. On XAUUSD, it is 100 troy ounces. On US30, it may be one unit of index value. On USDJPY, the pip value in account currency depends on the current USDJPY exchange rate. A position-sizing formula that applies a fixed dollar-risk-per-lot calculation uniformly across these symbols will produce badly wrong sizes for most of them.

The solution is a metadata layer that queries the broker's contract specifications at runtime, normalizes tick values into a common account-currency denominator, and stores the results in a structure accessible to every component of the portfolio framework. This layer makes no trading decisions. It exists solely to ensure that every calculation consuming position size, risk budget, or monetary value is working with accurate, symbol-specific figures.

Note: If you missed Part 1 of this series, you can read it here.


What the Broker Provides and What Must Be Derived

MetaTrader 5 exposes contract specification data through SymbolInfoDouble(), SymbolInfoInteger(), and SymbolInfoString(). The raw values returned by these functions are not always directly usable. Several key quantities must be derived rather than read directly.

Tick value in account currency is the most critical derived quantity. MetaTrader 5 provides SYMBOL_TRADE_TICK_VALUE, which returns the monetary value of one tick move already expressed in account (deposit) currency. When the symbol's profit currency differs from the account currency — EURJPY on a USD account, for example — the terminal performs the cross-rate conversion internally before returning this value; the registry does not perform any conversion of its own, it only reads the result. Because the underlying cross rate moves, the value this call returns is only correct at the moment it is read, so the metadata layer must refresh it periodically rather than reading it once at startup.

Lot size normalization: requires the minimum lot (SYMBOL_VOLUME_MIN), maximum lot (SYMBOL_VOLUME_MAX), and lot step (SYMBOL_VOLUME_STEP). A computed lot size must be rounded down to the nearest lot step and capped at the maximum before submission. If the resulting size falls below the broker's minimum lot, this implementation returns 0.0 rather than rounding up to the minimum, so it does not silently exceed the caller's intended risk. Skipping this produces an ERR_INVALID_VOLUME rejection at the broker level.

Digits and point size: determine how price levels are formatted for stop-loss and take-profit calculations. SYMBOL_DIGITS gives the number of decimal places, and SYMBOL_POINT gives the value of one point. For five-digit brokers, one pip equals ten points on most FX pairs. The metadata layer stores both values and provides a helper that converts a pip distance into the correct point count for a given symbol.

Contract Specification Comparison

Figure 1: Contract Specification Comparison: EURUSD and XAUUSD read directly since their profit currency already matches the account. USDJPY, US30, and USDCAD need a cross-rate or tick/point ratio derivation to get an accurate account-currency value.


Dynamic Symbol Loading

Rather than hard-coding the managed symbol list inside the Portfolio Controller, the framework in this part reads a plain-text universe file — one symbol per line — from the terminal's shared Common\Files folder. The Portfolio Controller reads this file at initialization, validates each entry against the terminal's market watch, and builds its internal universe at runtime.

This has practical advantages. Adding or removing symbols from the portfolio does not require recompiling any EA. The list can be edited while the terminal is running, since it is re-read on every controller initialization. Symbols unavailable in market watch are skipped with a logged warning rather than causing a hard failure.

The universe file must be saved as plain single-byte (ANSI) text, for example, Windows-1252 on Western European systems, not as UTF-8 or UTF-16. Many text editors default to a UTF-16 "Unicode" encoding when saving .txt files, which stores every character as two bytes rather than one, and the registry opens the file with the FILE_ANSI flag, which reads single-byte text only. If the file is UTF-16, or contains multi-byte UTF-8 sequences, it will be misread: byte pairs or byte sequences become garbled characters, and extracted symbol names fail validation. Since symbol names are ASCII in practice, saving as plain single-byte text from the start avoids this entirely.

Validation against market watch is mandatory before loading metadata. SymbolInfoDouble() called on a symbol not present in market watch returns 0.0 without setting an error code, which would otherwise produce silently incorrect metadata. SymbolSelect() adds a symbol to market watch if it is available on the broker's server, after which the metadata query can proceed safely.

Dynamic Symbol Loading Sequence

Figure 2: Dynamic Symbol Loading Sequence: each symbol is validated with SymbolSelect() and its trade mode before contract specifications are queried and stored. Invalid symbols are skipped with a logged warning and the loop continues to the next entry.


The Symbol Metadata Structure

All contract specification data for one symbol is encapsulated in a single structure. Centralizing this data prevents the scattered SymbolInfoDouble() calls that proliferate in unstructured code and eliminates the risk of inconsistent values being used in different parts of the system.

//+------------------------------------------------------------------+
//|                                              SymbolMetadata.mqh  |
//|         Contract specification structure and metadata utilities  |
//+------------------------------------------------------------------+
#ifndef SYMBOL_METADATA_MQH
#define SYMBOL_METADATA_MQH

//--- Symbol metadata container
struct SymbolMetadata
  {
   string            Symbol;              // Symbol name as returned by the terminal
   double            ContractSize;        // Units per standard lot
   double            TickSize;            // Minimum price movement
   double            TickValue;           // Account-currency value of one tick per lot
   double            PointSize;           // Value of SYMBOL_POINT
   int               Digits;              // Decimal places in price
   double            VolumeMin;           // Minimum lot size
   double            VolumeMax;           // Maximum lot size
   double            VolumeStep;          // Lot increment step
   double            SpreadPoints;        // Current spread in points
   string            ProfitCurrency;      // Currency in which profits are denominated (also the quote currency for a standard FX pair)
   string            BaseCurrency;        // Base currency (first in pair)
   bool              IsLoaded;            // True if metadata was loaded successfully
   datetime          LoadTimestamp;       // Time at which metadata was last refreshed
  };

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

The IsLoaded flag and LoadTimestamp field serve distinct purposes. IsLoaded indicates whether the initial metadata load succeeded. LoadTimestamp supports the periodic refresh mechanism: any consumer of the metadata can check whether the tick value is stale and request a refresh if the timestamp is older than a configurable threshold.


The Symbol Registry Class

The registry class owns the metadata array, manages loading and refresh operations, and provides the access interface used by the Portfolio Controller and every Instrument Agent. No other component calls SymbolInfoDouble() directly after initialization. All symbol data flows through the registry.

MQL5 permits reference parameters only in function argument lists, not as local variable aliases. Every method below reads and writes struct fields through direct array indexing — m_Symbols[idx].Field — rather than through a local alias, since MQL5 has no equivalent to a C++-style local reference binding.

//+------------------------------------------------------------------+
//|                                             SymbolRegistry.mqh   |
//|       Dynamic symbol universe management and metadata registry   |
//+------------------------------------------------------------------+
#ifndef SYMBOL_REGISTRY_MQH
#define SYMBOL_REGISTRY_MQH

#include "SymbolMetadata.mqh"

//--- Maximum number of managed symbols
#define MAX_SYMBOLS 50

//+------------------------------------------------------------------+
//| CSymbolRegistry                                                  |
//+------------------------------------------------------------------+
class CSymbolRegistry
  {
private:
   SymbolMetadata    m_Symbols[MAX_SYMBOLS];
   int               m_SymbolCount;
   string            m_UniverseFilePath;
   int               m_RefreshIntervalSeconds;
   datetime          m_LastRefreshTime;

   //--- Internal helpers
   bool              LoadSymbolFromBroker(const string symbol, int index);
   bool              ValidateSymbol(const string symbol) const;
   int               FindSymbolIndex(const string symbol) const;
   bool              ReadUniverseFile(string &outSymbols[], int &outCount);

public:
                     CSymbolRegistry();
                    ~CSymbolRegistry();

   bool              Initialize(const string universeFilePath,
                                int refreshIntervalSeconds = 300);
   bool              RefreshTickValues();
   bool              IsRefreshDue() const;

   //--- Accessors
   int               GetSymbolCount() const { return m_SymbolCount; }
   bool              GetMetadata(const string symbol,
                                 SymbolMetadata &outMeta) const;
   string            GetSymbolName(int index) const;
   double            GetTickValue(const string symbol) const;
   double            GetNormalizedLots(const string symbol,
                                       double rawLots) const;
   double            PipsToPoints(const string symbol,
                                  double pips) const;
   double            PointsToAccountCurrency(const string symbol,
         double points, double lots) const;
  };

//+------------------------------------------------------------------+
//| Constructor                                                      |
//+------------------------------------------------------------------+
CSymbolRegistry::CSymbolRegistry()
  {
   m_SymbolCount            = 0;
   m_UniverseFilePath       = "";
   m_RefreshIntervalSeconds = 300;
   m_LastRefreshTime        = 0;

// FIX: ArrayInitialize() does not support struct arrays and
// m_Symbols.IsLoaded is not valid MQL5 syntax — the loop below
// is the only initialization needed.
   for(int i = 0; i < MAX_SYMBOLS; i++)
      m_Symbols[i].IsLoaded = false;
  }

//+------------------------------------------------------------------+
//| Destructor                                                       |
//+------------------------------------------------------------------+
CSymbolRegistry::~CSymbolRegistry()
  {
// No heap allocations to release in this implementation
  }

//+------------------------------------------------------------------+
//| Initialize: load universe file and populate metadata             |
//+------------------------------------------------------------------+
bool CSymbolRegistry::Initialize(const string universeFilePath,
                                 int refreshIntervalSeconds = 300)
  {
   m_UniverseFilePath       = universeFilePath;
   m_RefreshIntervalSeconds = refreshIntervalSeconds;
   m_SymbolCount            = 0;

   string symbols[];
   int    count = 0;

   if(!ReadUniverseFile(symbols, count))
     {
      Print("SymbolRegistry: Failed to read universe file: ", universeFilePath);
      return(false);
     }

   if(count == 0)
     {
      Print("SymbolRegistry: Universe file is empty or contains no valid entries.");
      return(false);
     }

   int loadedCount = 0;

   for(int i = 0; i < count && i < MAX_SYMBOLS; i++)
     {
      string sym = symbols[i];

      if(!ValidateSymbol(sym))
        {
         Print("SymbolRegistry: Symbol unavailable or not tradeable, skipping: ",
               sym);
         continue;
        }

      if(LoadSymbolFromBroker(sym, loadedCount))
        {
         loadedCount++;
         Print("SymbolRegistry: Loaded metadata for ", sym);
        }
      else
        {
         Print("SymbolRegistry: Metadata load failed for ", sym, ". Skipping.");
        }
     }

   m_SymbolCount     = loadedCount;
   m_LastRefreshTime = TimeCurrent();

   Print("SymbolRegistry: Initialization complete. ",
         m_SymbolCount, " symbols loaded.");

   return(m_SymbolCount > 0);
  }

//+------------------------------------------------------------------+
//| Reads the symbol universe from a plain-text file                 |
//+------------------------------------------------------------------+
bool CSymbolRegistry::ReadUniverseFile(string &outSymbols[], int &outCount)
  {
   outCount = 0;

   int handle = FileOpen(m_UniverseFilePath,
                         FILE_READ | FILE_TXT | FILE_ANSI | FILE_COMMON);

   if(handle == INVALID_HANDLE)
     {
      Print("SymbolRegistry: Cannot open universe file. Error: ", GetLastError());
      return(false);
     }

   ArrayResize(outSymbols, MAX_SYMBOLS);

   while(!FileIsEnding(handle) && outCount < MAX_SYMBOLS)
     {
      string line = FileReadString(handle);

      //--- Strip whitespace and skip empty lines or comments
      StringTrimRight(line);
      StringTrimLeft(line);

      if(StringLen(line) == 0)
         continue;
      if(StringGetCharacter(line, 0) == '#')
         continue;

      //--- Skip a symbol already collected from an earlier line, so the
      //--- same symbol cannot be loaded twice and publish conflicting
      //--- PC_MaxLots_<symbol> globals
      bool alreadyPresent = false;
      for(int p = 0; p < outCount; p++)
        {
         if(outSymbols[p] == line)
           {
            alreadyPresent = true;
            break;
           }
        }

      if(alreadyPresent)
        {
         Print("SymbolRegistry: Duplicate symbol in universe file, skipping: ", line);
         continue;
        }

      outSymbols[outCount] = line;
      outCount++;
     }

   if(!FileIsEnding(handle))
      Print("SymbolRegistry: Universe file exceeds MAX_SYMBOLS (", MAX_SYMBOLS,
            "). Remaining entries were ignored.");

   FileClose(handle);
   return(true);
  }

//+------------------------------------------------------------------+
//| Validates that a symbol is available and tradeable               |
//+------------------------------------------------------------------+
bool CSymbolRegistry::ValidateSymbol(const string symbol) const
  {
//--- Attempt to add to market watch if not already present
   if(!SymbolSelect(symbol, true))
     {
      Print("SymbolRegistry: SymbolSelect failed for ", symbol,
            ". Error: ", GetLastError());
      return(false);
     }

//--- Confirm the symbol is open for new trades. SYMBOL_TRADE_MODE_DISABLED
//--- blocks all trading; SYMBOL_TRADE_MODE_CLOSEONLY blocks new positions
//--- specifically, which this framework needs to open. Long-only and
//--- short-only modes are left to pass, since direction suitability
//--- depends on the strategy rather than on registry-level validation.
   ENUM_SYMBOL_TRADE_MODE tradeMode =
      (ENUM_SYMBOL_TRADE_MODE)SymbolInfoInteger(symbol, SYMBOL_TRADE_MODE);

   if(tradeMode == SYMBOL_TRADE_MODE_DISABLED ||
      tradeMode == SYMBOL_TRADE_MODE_CLOSEONLY)
     {
      Print("SymbolRegistry: Symbol not open for new trades: ", symbol);
      return(false);
     }

   return(true);
  }

//+------------------------------------------------------------------+
//| Loads all contract specification data for one symbol             |
//+------------------------------------------------------------------+
bool CSymbolRegistry::LoadSymbolFromBroker(const string symbol, int index)
  {
   if(index < 0 || index >= MAX_SYMBOLS)
      return(false);

// FIX: local struct references ("SymbolMetadata &meta = m_Symbols[index];")
// are not valid MQL5 syntax outside a function parameter list.
// Write through m_Symbols[index] directly instead.
   m_Symbols[index].Symbol         = symbol;
   m_Symbols[index].ContractSize   = SymbolInfoDouble(symbol, SYMBOL_TRADE_CONTRACT_SIZE);
   m_Symbols[index].TickSize       = SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_SIZE);
   m_Symbols[index].TickValue      = SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_VALUE);
   m_Symbols[index].PointSize      = SymbolInfoDouble(symbol, SYMBOL_POINT);
   m_Symbols[index].Digits         = (int)SymbolInfoInteger(symbol, SYMBOL_DIGITS);
   m_Symbols[index].VolumeMin      = SymbolInfoDouble(symbol, SYMBOL_VOLUME_MIN);
   m_Symbols[index].VolumeMax      = SymbolInfoDouble(symbol, SYMBOL_VOLUME_MAX);
   m_Symbols[index].VolumeStep     = SymbolInfoDouble(symbol, SYMBOL_VOLUME_STEP);
   m_Symbols[index].SpreadPoints   = (double)SymbolInfoInteger(symbol, SYMBOL_SPREAD);
   m_Symbols[index].ProfitCurrency = SymbolInfoString(symbol, SYMBOL_CURRENCY_PROFIT);
   m_Symbols[index].BaseCurrency   = SymbolInfoString(symbol, SYMBOL_CURRENCY_BASE);

//--- Sanity checks on critical fields
   if(m_Symbols[index].ContractSize <= 0.0 || m_Symbols[index].TickSize <= 0.0 ||
      m_Symbols[index].TickValue    <= 0.0 || m_Symbols[index].VolumeStep <= 0.0)
     {
      Print("SymbolRegistry: Invalid contract spec for ", symbol,
            ". ContractSize=", m_Symbols[index].ContractSize,
            " TickSize=", m_Symbols[index].TickSize,
            " TickValue=", m_Symbols[index].TickValue);
      m_Symbols[index].IsLoaded = false;
      return(false);
     }

   m_Symbols[index].IsLoaded      = true;
   m_Symbols[index].LoadTimestamp = TimeCurrent();

   return(true);
  }

//+------------------------------------------------------------------+
//| Refresh tick values for all loaded symbols (call periodically)   |
//+------------------------------------------------------------------+
bool CSymbolRegistry::RefreshTickValues()
  {
   bool allSucceeded = true;

   for(int i = 0; i < m_SymbolCount; i++)
     {
      if(!m_Symbols[i].IsLoaded)
         continue;

      string sym = m_Symbols[i].Symbol;

      double freshTickValue = SymbolInfoDouble(sym, SYMBOL_TRADE_TICK_VALUE);

      if(freshTickValue <= 0.0)
        {
         Print("SymbolRegistry: Tick value refresh failed for ", sym);
         allSucceeded = false;
         continue;
        }

      m_Symbols[i].TickValue      = freshTickValue;
      m_Symbols[i].SpreadPoints   = (double)SymbolInfoInteger(sym, SYMBOL_SPREAD);
      m_Symbols[i].LoadTimestamp  = TimeCurrent();
     }

   m_LastRefreshTime = TimeCurrent();
   return(allSucceeded);
  }

//+------------------------------------------------------------------+
//| Return true if a tick value refresh is due                       |
//+------------------------------------------------------------------+
bool CSymbolRegistry::IsRefreshDue() const
  {
   return((int)(TimeCurrent() - m_LastRefreshTime) >= m_RefreshIntervalSeconds);
  }

//+------------------------------------------------------------------+
//| Retrieves the full metadata for a symbol by name                 |
//+------------------------------------------------------------------+
bool CSymbolRegistry::GetMetadata(const string symbol,
                                  SymbolMetadata &outMeta) const
  {
   int idx = FindSymbolIndex(symbol);
   if(idx < 0)
      return(false);

   outMeta = m_Symbols[idx];
   return(true);
  }

//+------------------------------------------------------------------+
//| Return the symbol name at a given index position                 |
//+------------------------------------------------------------------+
string CSymbolRegistry::GetSymbolName(int index) const
  {
   if(index < 0 || index >= m_SymbolCount)
      return("");
   return(m_Symbols[index].Symbol);
  }

//+------------------------------------------------------------------+
//| Return the current tick value for a symbol in account currency   |
//+------------------------------------------------------------------+
double CSymbolRegistry::GetTickValue(const string symbol) const
  {
   int idx = FindSymbolIndex(symbol);
   if(idx < 0)
      return(0.0);
   return(m_Symbols[idx].TickValue);
  }

//+------------------------------------------------------------------+
//| Normalize a raw lot value to the symbol's volume constraints     |
//+------------------------------------------------------------------+
double CSymbolRegistry::GetNormalizedLots(const string symbol,
      double rawLots) const
  {
   int idx = FindSymbolIndex(symbol);
   if(idx < 0)
      return(0.0);

// FIX: replaced "const SymbolMetadata &meta = m_Symbols[idx];" with
// direct indexed access — local references are not valid MQL5 syntax.
   if(m_Symbols[idx].VolumeStep <= 0.0)
      return(0.0);

   double normalized = MathFloor(rawLots / m_Symbols[idx].VolumeStep)
                       * m_Symbols[idx].VolumeStep;

//--- Round to the step's implied precision to clear floating-point
//--- artifacts (e.g. 0.07999999999 instead of 0.08) that MetaTrader
//--- can otherwise reject as an invalid volume
   int stepDigits = (int)MathRound(-MathLog10(m_Symbols[idx].VolumeStep));
   normalized = NormalizeDouble(normalized, MathMax(0, stepDigits));

//--- Below the minimum tradable size: the position is not achievable
//--- at this risk level. Returning 0 here, rather than rounding up to
//--- VolumeMin, keeps the function from silently exceeding the caller's
//--- risk-computed size.
   if(normalized < m_Symbols[idx].VolumeMin)
      return(0.0);

   normalized = MathMin(normalized, m_Symbols[idx].VolumeMax);
   return(normalized);
  }

//+------------------------------------------------------------------+
//| Convert a pip distance to points for a specific symbol           |
//+------------------------------------------------------------------+
double CSymbolRegistry::PipsToPoints(const string symbol, double pips) const
  {
   int idx = FindSymbolIndex(symbol);
   if(idx < 0)
      return(0.0);

// FIX: replaced local reference with direct indexed access.
//--- For 5-digit FX pairs, 1 pip = 10 points
//--- For 3-digit JPY pairs, 1 pip = 10 points (same logic)
//--- For metals and indices, pip convention varies — use tick size as base unit
   double pipInPoints = 0.0;

   if(m_Symbols[idx].Digits == 5 || m_Symbols[idx].Digits == 3)
      pipInPoints = 10.0;
   else
      if(m_Symbols[idx].Digits == 2 || m_Symbols[idx].Digits == 4)
         pipInPoints = 1.0;
      else
         pipInPoints = m_Symbols[idx].TickSize / m_Symbols[idx].PointSize;

   return(pips * pipInPoints);
  }

//+------------------------------------------------------------------+
//| Convert a point distance to account currency value for N lots    |
//+------------------------------------------------------------------+
double CSymbolRegistry::PointsToAccountCurrency(const string symbol,
      double points,
      double lots) const
  {
   int idx = FindSymbolIndex(symbol);
   if(idx < 0)
      return(0.0);

// FIX: replaced local reference with direct indexed access.
   if(m_Symbols[idx].TickSize <= 0.0)
      return(0.0);

//--- Value of one point move = TickValue * (PointSize / TickSize)
   double pointValue = m_Symbols[idx].TickValue *
                       (m_Symbols[idx].PointSize / m_Symbols[idx].TickSize);

   return(points * pointValue * lots);
  }

//+------------------------------------------------------------------+
//| Internal: find index of a symbol in the registry array           |
//+------------------------------------------------------------------+
int CSymbolRegistry::FindSymbolIndex(const string symbol) const
  {
   for(int i = 0; i < m_SymbolCount; i++)
     {
      if(m_Symbols[i].Symbol == symbol && m_Symbols[i].IsLoaded)
         return(i);
     }
   return(-1);
  }

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


Tick Value Normalization Across Asset Classes

The PointsToAccountCurrency() method deserves closer examination, because the derivation it performs is one of the most consistently misunderstood calculations in MQL5 development.

MetaTrader 5 reports SYMBOL_TRADE_TICK_VALUE as the profit or loss in account currency generated by one tick move on one lot. A tick move equals SYMBOL_TRADE_TICK_SIZE in price terms. The point size reported by SYMBOL_POINT may or may not equal the tick size — on most FX pairs they are equal, but on CFD symbols such as indices or commodities, the tick size is often a multiple of the point size.

To convert a distance measured in points into an account-currency monetary value, the formula is:

MonetaryValue = Points × (PointSize / TickSize) × TickValue × Lots

The ratio PointSize / TickSize adjusts for the case where the symbol's minimum price increment (tick size) is larger than one point. For standard FX pairs where tick size equals point size, this ratio is 1.0 and the formula reduces to the familiar Points × TickValue × Lots. For an index CFD where tick size might be 1.0 and point size 0.01, the ratio is 0.01, correctly scaling down the monetary value per point.

The following table illustrates this normalization across a representative set of symbols, assuming a USD-denominated account:

Symbol TickSize PointSize TickValue (1 lot) Ratio PointValue (1 lot)
EURUSD 0.00001 0.00001 $1.00 1.0 $1.00
USDJPY 0.001 0.001 ~$0.91* 1.0 ~$0.91
XAUUSD 0.01 0.01 $1.00 1.0 $1.00
US30 1.0 0.01 $1.00 0.01 $0.01
GBPJPY 0.001 0.001 ~$0.91* 1.0 ~$0.91

Rate-dependent; the terminal converts to account currency at the prevailing cross rate.

Tick Value Conversion

Figure 3: Tick Value Conversion, JPY Profit Currency: the terminal fetches the live USDJPY rate and converts the raw JPY tick value into USD automatically. The registry just reads SYMBOL_TRADE_TICK_VALUE directly, refreshed periodically as the cross rate moves.


Integrating the Registry into the Portfolio Controller

The Portfolio Controller instantiates a CSymbolRegistry object, initializes it from the universe file, and exposes per-symbol lot limits through the shared global variable channel. These limits are derived from actual contract specifications — tick size, tick value, volume step — combined with a fixed nominal stop distance used as a placeholder rather than a calibrated risk distance (see Design Decisions below).

The registry instance is owned by the Portfolio Controller. Instrument Agents do not instantiate their own copy of the controller's registry — each agent maintains a smaller, self-contained registry scoped to its own symbol, described below. This design ensures that contract specification queries happen in one place at a controlled frequency for the controller's universe, rather than being scattered across every agent's tick handler.

//+------------------------------------------------------------------+
//|                                    PortfolioController_v2.mq5    |
//|         Portfolio Controller with symbol registry integration    |
//+------------------------------------------------------------------+
#property copyright   "Institutional Portfolio Series"
#property version     "2.00"
#property strict

#include <Multi_Currency_Portfolio_Engine_Part_2/SymbolRegistry.mqh>

//--- Input parameters
input double inp_MaxPortfolioRiskPct       = 2.0;                   // Maximum total portfolio risk %
input double inp_RiskPerSymbolPct          = 0.4;                   // Risk % allocated per symbol
input int    inp_TimerIntervalSeconds      = 10;                    // Portfolio update interval (s)
input int    inp_MetaRefreshIntervalSecs   = 300;                   // Metadata refresh interval (s)
input string inp_UniverseFile              = "symbol_universe.txt"; // Universe filename

//--- Global variable name constants
#define GV_READY            "PC_ControllerReady"
#define GV_RISK_BUDGET      "PC_RiskBudgetPct"
#define GV_GLOBAL_HALT      "SYS_GlobalHalt"

//--- Registry instance
CSymbolRegistry g_Registry;

//--- Runtime state
double   g_CurrentEquity  = 0.0;
bool     g_HaltActive     = false;

//+------------------------------------------------------------------+
//| Expert initialization function                                   |
//+------------------------------------------------------------------+
int OnInit()
  {
   GlobalVariableSet(GV_GLOBAL_HALT, 1.0);
   GlobalVariableSet(GV_READY,       0.0);
   GlobalVariableSet(GV_RISK_BUDGET, 0.0);

//--- Initialize symbol registry from universe file
   if(!g_Registry.Initialize(inp_UniverseFile, inp_MetaRefreshIntervalSecs))
     {
      Print("PortfolioController: Registry initialization failed. Aborting.");
      return(INIT_FAILED);
     }

//--- Build per-symbol lot limit globals from metadata
   PublishSymbolLotLimits();

//--- Initial portfolio state and broadcast
   if(!UpdateAndBroadcast())
     {
      Print("PortfolioController: Initial broadcast failed. Aborting.");
      return(INIT_FAILED);
     }

   GlobalVariableSet(GV_GLOBAL_HALT, 0.0);
   GlobalVariableSet(GV_READY,       1.0);

   EventSetTimer(inp_TimerIntervalSeconds);

   Print("PortfolioController v2: Ready. Symbols: ",
         g_Registry.GetSymbolCount());
   return(INIT_SUCCEEDED);
  }

//+------------------------------------------------------------------+
//| Deinitialization                                                 |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
   GlobalVariableSet(GV_GLOBAL_HALT, 1.0);
   GlobalVariableSet(GV_READY,       0.0);
   EventKillTimer();
  }

//+------------------------------------------------------------------+
//| Timer: refresh metadata if due, then update portfolio state      |
//+------------------------------------------------------------------+
void OnTimer()
  {
   if(g_Registry.IsRefreshDue())
     {
      if(!g_Registry.RefreshTickValues())
        {
         Print("PortfolioController: Tick value refresh encountered errors.");
        }
      PublishSymbolLotLimits();
     }

   UpdateAndBroadcast();
  }

//+------------------------------------------------------------------+
//| Publish per-symbol maximum lot sizes derived from metadata       |
//+------------------------------------------------------------------+
void PublishSymbolLotLimits()
  {
   int count = g_Registry.GetSymbolCount();

   for(int i = 0; i < count; i++)
     {
      string sym = g_Registry.GetSymbolName(i);
      if(sym == "")
         continue;

      SymbolMetadata meta;
      if(!g_Registry.GetMetadata(sym, meta))
         continue;

      //--- Calculate max lots from risk budget and tick value
      double equityToRisk = AccountInfoDouble(ACCOUNT_EQUITY)
                            * (inp_RiskPerSymbolPct / 100.0);

      double maxLots = 0.0;

      if(meta.TickValue > 0.0 && meta.TickSize > 0.0)
        {
         //--- Use a nominal 50-pip risk distance for lot limit calculation
         //--- This will be replaced with ATR-based sizing in Part 4
         double nominalPips      = 50.0;
         double pipsInPoints     = g_Registry.PipsToPoints(sym, nominalPips);
         double riskPerLot       = g_Registry.PointsToAccountCurrency(
                                      sym, pipsInPoints, 1.0);

         if(riskPerLot > 0.0)
            maxLots = equityToRisk / riskPerLot;
        }

      //--- Normalize to symbol volume constraints
      maxLots = g_Registry.GetNormalizedLots(sym, maxLots);

      //--- Write to shared global
      string varName = "PC_MaxLots_" + sym;
      GlobalVariableSet(varName, maxLots);
     }
  }

//+------------------------------------------------------------------+
//| Update portfolio state and broadcast                             |
//+------------------------------------------------------------------+
bool UpdateAndBroadcast()
  {
   g_CurrentEquity = AccountInfoDouble(ACCOUNT_EQUITY);

   if(g_CurrentEquity <= 0.0)
      return(false);

//--- Aggregate reported open lots across all managed symbols
   double totalLots = 0.0;
   int    count     = g_Registry.GetSymbolCount();

   for(int i = 0; i < count; i++)
     {
      string sym     = g_Registry.GetSymbolName(i);
      string varName = "IA_" + sym + "_OpenLots";

      if(GlobalVariableCheck(varName))
         totalLots += GlobalVariableGet(varName);
     }

//--- Compute remaining budget (simplified; ATR-based version in Part 4)
   double balance             = AccountInfoDouble(ACCOUNT_BALANCE);
   double floatingDrawdownPct = (balance > 0.0)
                                ? ((balance - g_CurrentEquity) / balance) * 100.0
                                : 0.0;

   g_HaltActive = (floatingDrawdownPct >= inp_MaxPortfolioRiskPct * 3.0);

   double remainingBudget = MathMax(0.0,
                                    inp_MaxPortfolioRiskPct - floatingDrawdownPct);

   GlobalVariableSet(GV_RISK_BUDGET, remainingBudget);
   GlobalVariableSet(GV_GLOBAL_HALT, g_HaltActive ? 1.0 : 0.0);

   return(true);
  }
//+------------------------------------------------------------------+


Updating the Instrument Agent to Use Metadata-Derived Lot Limits

The Instrument Agent requires two changes relative to the framework skeleton established earlier in this series. First, its lot size calculation now reads from the per-symbol lot limit global published by the Portfolio Controller, rather than a single uniform lot ceiling. Second, it gains a local CSymbolRegistry instance scoped to its own symbol, used for lot-size normalization and pip-to-point conversion when computing position size. Stop-loss and take-profit placement are not implemented in this part — the order calls below submit both at zero — since the signal logic that would size a stop is still a stub; both are introduced once the risk model built in later parts has a distance to size them from.

The agent checks readiness on every tick until the controller is confirmed. After a timeout, it continues checking on the timer even in degraded mode. This matters because controller startup time is not fixed — a large symbol universe or a slow first contact with the broker can push initialization past an agent's timeout window. Without repeated checks, an agent may remain in degraded mode after the controller becomes available. In that case it would keep trading on fallback defaults instead of the controller-published limits. Re-checking on every timer cycle lets the agent recover automatically the moment the controller's readiness flag appears. Recovery here means the agent stops sizing new trades from fallback values once the controller is confirmed ready — it does not retroactively adjust or flag any position already opened under degraded-mode sizing before that point.

//+------------------------------------------------------------------+
//|                                          InstrumentAgent_v2.mq5  |
//|                 Instrument Agent with metadata-aware lot sizing  |
//+------------------------------------------------------------------+
#property copyright   "Institutional Portfolio Series"
#property version     "2.00"
#property strict

#include <Trade\Trade.mqh>
#include <Multi_Currency_Portfolio_Engine_Part_2/SymbolRegistry.mqh>

//--- Input parameters
input string inp_ManagedSymbol          = "EURUSD";
input double inp_BaseRiskPct            = 0.4;
input int    inp_ReadinessTimeoutSecs   = 60;
input int    inp_MagicNumber            = 100001;
input int    inp_TimerIntervalSeconds   = 15;
input int    inp_MetaRefreshSecs        = 300;

//--- Communication constants
#define GV_READY        "PC_ControllerReady"
#define GV_RISK_BUDGET  "PC_RiskBudgetPct"
#define GV_GLOBAL_HALT  "SYS_GlobalHalt"

//--- Runtime objects
CTrade         g_Trade;
CSymbolRegistry g_LocalRegistry;

//--- Runtime state
bool     g_PCReady        = false;
bool     g_DegradedMode   = false;
datetime g_InitTime       = 0;

//+------------------------------------------------------------------+
//| Expert initialization function                                   |
//+------------------------------------------------------------------+
int OnInit()
  {
   g_InitTime = TimeCurrent();

   g_Trade.SetExpertMagicNumber(inp_MagicNumber);
   g_Trade.SetDeviationInPoints(20);

//--- Select a filling mode the broker actually supports for this symbol,
//--- rather than assuming FOK is available everywhere
   long fillingMask = SymbolInfoInteger(inp_ManagedSymbol, SYMBOL_FILLING_MODE);

   if((fillingMask & SYMBOL_FILLING_FOK) != 0)
      g_Trade.SetTypeFilling(ORDER_FILLING_FOK);
   else
      if((fillingMask & SYMBOL_FILLING_IOC) != 0)
         g_Trade.SetTypeFilling(ORDER_FILLING_IOC);
      else
         g_Trade.SetTypeFilling(ORDER_FILLING_RETURN);

//--- Initialize local registry for this symbol only
//--- The agent creates a single-symbol universe file at runtime
   string localUniverse = "agent_universe_" + inp_ManagedSymbol + ".txt";

//--- Write a single-symbol universe file for local registry
   int handle = FileOpen(localUniverse,
                         FILE_WRITE | FILE_TXT | FILE_ANSI | FILE_COMMON);
   if(handle != INVALID_HANDLE)
     {
      FileWriteString(handle, inp_ManagedSymbol + "\n");
      FileClose(handle);
     }

   if(!g_LocalRegistry.Initialize(localUniverse, inp_MetaRefreshSecs))
     {
      Print("InstrumentAgent [", inp_ManagedSymbol,
            "]: Local registry init failed. Aborting.");
      return(INIT_FAILED);
     }

//--- Register state globals
   GlobalVariableSet("IA_" + inp_ManagedSymbol + "_OpenLots",   0.0);
   GlobalVariableSet("IA_" + inp_ManagedSymbol + "_LastSignal", 0.0);

   EventSetTimer(inp_TimerIntervalSeconds);

   Print("InstrumentAgent [", inp_ManagedSymbol,
         "]: v2 initialized. Awaiting PC readiness.");
   return(INIT_SUCCEEDED);
  }

//+------------------------------------------------------------------+
//| Deinitialization                                                 |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
   GlobalVariableSet("IA_" + inp_ManagedSymbol + "_OpenLots", 0.0);
   EventKillTimer();
   Print("InstrumentAgent [", inp_ManagedSymbol, "]: Deinitialized.");
  }

//+------------------------------------------------------------------+
//| Tick handler                                                     |
//+------------------------------------------------------------------+
void OnTick()
  {
   if(!g_PCReady)
     {
      CheckControllerReadiness();
      if(!g_PCReady)
         return;
     }

   if(IsGlobalHaltActive())
      return;

   if(g_LocalRegistry.IsRefreshDue())
      g_LocalRegistry.RefreshTickValues();

   double signal = EvaluateSignal();
   if(signal == 0.0)
      return;

   double lots = ComputeMetadataAwareLots();
   if(lots <= 0.0)
      return;

   if(signal > 0.0)
     {
      g_Trade.Buy(lots, inp_ManagedSymbol, 0, 0, 0, "IA_Entry");
     }
   else
      if(signal < 0.0)
        {
         g_Trade.Sell(lots, inp_ManagedSymbol, 0, 0, 0, "IA_Entry");
        }

   GlobalVariableSet("IA_" + inp_ManagedSymbol + "_LastSignal", signal);
  }

//+------------------------------------------------------------------+
//| Timer handler                                                    |
//+------------------------------------------------------------------+
void OnTimer()
  {
   ReportOpenLots();

//--- Re-check even if already in degraded mode, so the agent can
//--- recover automatically once the controller comes online
   if(!g_PCReady || g_DegradedMode)
      CheckControllerReadiness();
  }

//+------------------------------------------------------------------+
//| Compute lot size using PC-published metadata-derived limits      |
//+------------------------------------------------------------------+
double ComputeMetadataAwareLots()
  {
   double maxLots        = 0.0;
   double remainingBudget = 0.0;

   if(!g_DegradedMode)
     {
      //--- Read per-symbol lot limit published by Portfolio Controller
      string lotLimitVar = "PC_MaxLots_" + inp_ManagedSymbol;

      if(GlobalVariableCheck(lotLimitVar))
         maxLots = GlobalVariableGet(lotLimitVar);

      if(GlobalVariableCheck(GV_RISK_BUDGET))
         remainingBudget = GlobalVariableGet(GV_RISK_BUDGET);
     }
   else
     {
      remainingBudget = inp_BaseRiskPct * 0.5;
      maxLots         = g_LocalRegistry.GetNormalizedLots(
                           inp_ManagedSymbol, 0.5);
     }

   if(maxLots <= 0.0 || remainingBudget <= 0.0)
      return(0.0);

//--- Scale by budget fraction
   double budgetFraction = MathMin(1.0, remainingBudget / inp_BaseRiskPct);
   double rawLots        = maxLots * budgetFraction;

   return(g_LocalRegistry.GetNormalizedLots(inp_ManagedSymbol, rawLots));
  }

//+------------------------------------------------------------------+
//| Report open lots to Portfolio Controller                         |
//+------------------------------------------------------------------+
void ReportOpenLots()
  {
   double total = 0.0;

   for(int i = PositionsTotal() - 1; i >= 0; i--)
     {
      if(PositionGetTicket(i) == 0)
         continue;
      if(PositionGetString(POSITION_SYMBOL)  != inp_ManagedSymbol)
         continue;
      if(PositionGetInteger(POSITION_MAGIC)  != inp_MagicNumber)
         continue;

      total += PositionGetDouble(POSITION_VOLUME);
     }

   GlobalVariableSet("IA_" + inp_ManagedSymbol + "_OpenLots", total);
  }

//+------------------------------------------------------------------+
//| Check Portfolio Controller readiness flag                        |
//+------------------------------------------------------------------+
void CheckControllerReadiness()
  {
   if(GlobalVariableCheck(GV_READY) &&
      GlobalVariableGet(GV_READY) == 1.0)
     {
      g_PCReady      = true;
      g_DegradedMode = false;
      Print("InstrumentAgent [", inp_ManagedSymbol, "]: PC ready confirmed.");
      return;
     }

   if((int)(TimeCurrent() - g_InitTime) >= inp_ReadinessTimeoutSecs)
     {
      g_PCReady      = true;
      g_DegradedMode = true;
      Print("InstrumentAgent [", inp_ManagedSymbol,
            "]: Timeout. Degraded mode active.");
     }
  }

//+------------------------------------------------------------------+
//| Check global halt flag                                           |
//+------------------------------------------------------------------+
bool IsGlobalHaltActive()
  {
   if(!GlobalVariableCheck(GV_GLOBAL_HALT))
      return(false);
   return(GlobalVariableGet(GV_GLOBAL_HALT) == 1.0);
  }

//+------------------------------------------------------------------+
//| Signal evaluation stub                                           |
//+------------------------------------------------------------------+
double EvaluateSignal()
  {
   return(0.0);
  }
//+------------------------------------------------------------------+


Design Decisions and Trade-offs

Registry ownership in the Instrument Agent: The agent maintains its own CSymbolRegistry instance loaded with a single symbol rather than querying the Portfolio Controller for metadata at runtime. This avoids adding a metadata query path through the global variable channel, which is not designed to carry structured data. The agent writes a single-symbol universe file at initialization — a minor file I/O cost paid once — and the registry is thereafter self-contained. The Portfolio Controller and each agent maintain independent metadata copies, a small memory cost accepted in exchange for clean component separation.

File encoding for universe files: To avoid ambiguity in how the terminal interprets text encoding, this framework opens universe files explicitly with FILE_ANSI and expects plain single-byte text. Saving the file as UTF-16 or UTF-8 can lead to misread symbol names in this implementation.

The 50-pip nominal risk distance: The PublishSymbolLotLimits() function uses a hardcoded 50-pip nominal stop distance when computing maximum lots from equity risk budget. This is a deliberate placeholder. The number is conservative enough to avoid overexposure on initialization but is not calibrated to actual market volatility. Part 4 replaces this with ATR-normalized stop distances that adapt to each symbol's current volatility regime.

Tick value staleness: The RefreshTickValues() method updates SYMBOL_TRADE_TICK_VALUE at the configured interval. For most FX pairs in a stable session, tick value drift over five minutes is minimal. For symbols with significant cross-rate exposure — EURJPY, GBPJPY, or any symbol whose profit currency requires conversion through a volatile cross — the refresh interval should be shortened. The framework exposes this as a configurable input to accommodate different deployment contexts.

PipsToPoints() reliability outside FX: the digit-count heuristic used in PipsToPoints() — 10 points per pip for 5- and 3-digit symbols, 1 point per pip for 2- and 4-digit symbols, and a tick-to-point ratio otherwise — is a reliable convention for standard FX pairs. For metals, indices, crypto CFDs, and broker-issued synthetics, "pip" is not a standardized unit, and the tick-to-point ratio fallback can produce a distance that does not match any convention the broker documents. Deployments trading non-FX instruments should verify the derived figure against the broker's own contract specification before relying on it for sizing.

Spread is tracked but not yet used in sizing or risk: SpreadPoints is refreshed alongside tick value on every metadata refresh cycle, but no calculation in this part consumes it. For wide-spread conditions, the actual cost of entering a position can exceed what the nominal risk distance accounts for. This field is currently informational, reserved for a later part's execution-cost model.

Symmetric tick value assumption: all risk calculations in this part use SYMBOL_TRADE_TICK_VALUE as a single figure for both directions. Some instruments, mainly futures-style CFDs, expose separate SYMBOL_TRADE_TICK_VALUE_PROFIT and SYMBOL_TRADE_TICK_VALUE_LOSS values that can differ. This registry does not query either of those separately, so risk sizing on such instruments assumes a symmetric tick value that may not hold exactly.

No cross-symbol aggregation at the agent level, and no atomic authorization: each Instrument Agent receives a per-symbol lot limit and does not account for allocations to other agents. Aggregate enforcement is the Portfolio Controller's responsibility through the PC_RiskBudgetPct variable, but MQL5 global variables provide no locking mechanism, so this enforcement is not atomic. Multiple agents can read the same available budget and open positions before any of them has reported back updated open lots through IA_<symbol>_OpenLots, so a burst of simultaneous signals across several agents can, in practice, collectively exceed the intended portfolio limit before the next timer cycle catches up. Part 7 addresses this more rigorously through a correlated-loss detection and position unwind system.

Residual risk versus open-position risk: PC_RiskBudgetPct is computed from the account's current floating drawdown, not from the stop-loss risk of positions already open. A position sitting at breakeven still carries the risk of its eventual stop-loss, but this version's budget calculation treats it as consuming no capacity until it actually moves into drawdown. This means the published budget understates committed risk whenever open positions have unrealized but unbooked exposure. A proper fix requires reserving budget at the moment a position opens and releasing it when the position closes, which this part does not implement; Part 7's correlated-loss and drawdown-control system is where this gap is closed.

Metadata Pipeline — v2 Architecture

Figure 4: Metadata Pipeline — v2 Architecture: the universe file feeds the symbol registry, which publishes lot limits to shared global variables that Instrument Agents read for sizing. A timer-driven refresh cycle keeps tick values current without restarting the pipeline.


Conclusion

This part replaces the flat lot-ceiling assumption from the initial framework with a runtime-loaded symbol universe and a normalized contract specification layer. The CSymbolRegistry class centralizes every SymbolInfoDouble() and SymbolInfoInteger() query, validates symbol availability before attempting to load metadata, and provides the conversion utilities — PipsToPoints(), PointsToAccountCurrency(), and GetNormalizedLots() — that every downstream calculation in this series depends on.

The tick value normalization formula resolves the most common source of cross-symbol sizing errors: the assumption that monetary risk per lot is uniform across asset classes. With this layer in place, the framework applies identical risk budget logic to FX pairs, metals, and index CFDs for the specific problem of tick-value and pip-to-point normalization. Symbol-specific differences — pip conventions, tick-to-point ratios — are resolved once inside the registry's conversion utilities, so the position-sizing code that calls them carries no symbol-specific branching of its own, even though the registry's internals do. This does not extend to every dimension of cross-asset trading: stop-distance calibration, liquidity, order-filling support, and minimum-volume suitability are separate concerns the Design Decisions section above addresses individually, and several remain open limitations carried into later parts. The Instrument Agent's continued readiness polling ensures the framework recovers gracefully from startup timing variance rather than requiring manual restarts whenever controller initialization runs long.

Part 3 builds on this foundation by introducing a rolling correlation matrix computed over the symbol universe loaded here. The covariance estimator consumes symbol names directly from the registry and uses the price history retrieval functions that the metadata layer's validated symbol set makes safe to call.


Programs used in the article:

# Name Type Description
1 SymbolMetadata.mqh Include File Defines the SymbolMetadata structure holding all contract specification fields for one symbol, including load status and refresh timestamp.
2 SymbolRegistry.mqh Include File Implements CSymbolRegistry: dynamic symbol loading from a universe file, contract spec queries, tick value normalization, lot size clamping, and pip-to-point conversion. Reads universe files as single-byte (ANSI) text.
3 PortfolioController_v2.mq5 Expert Advisor (Master) Portfolio Controller integrating CSymbolRegistry. Publishes per-symbol metadata-derived lot limits to shared global variables alongside the risk budget and halt flag.
4 InstrumentAgent_v2.mq5 Expert Advisor (Slave) Instrument Agent using a local single-symbol CSymbolRegistry instance. Reads per-symbol lot limits from the Portfolio Controller's published globals, normalizes computed lot sizes through local registry utilities, and continues polling for controller readiness after entering degraded mode.
5 symbol_universe.txt Configuration File Plain-text file listing one symbol name per line, saved as single-byte (ANSI) text, read by the Portfolio Controller at initialization to build the managed symbol universe. Lines beginning with # are treated as comments and skipped.
6 Multi_Currency_Portfolio_Engine_Part_2.zip Zip Archive Zip archive containing all the attached files and their paths relative to the terminal's root folder.
Price Action Analysis Toolkit Development (Part 85): Visualizing and Reviewing Validated Double-Bottom Samples Price Action Analysis Toolkit Development (Part 85): Visualizing and Reviewing Validated Double-Bottom Samples
This part adds a visual review layer to the double-bottom workflow. It checks the chart context, moves to the recorded period, and renders the range, two qualifying lows, neckline, classification, score, and the first decisive close. The annotations mirror the validator's indices and prices, helping you audit outcomes without changing sample data.
Does Your EA Depend on Candle Boundaries? A Shifted-Bar Stress Test in MQL5 Does Your EA Depend on Candle Boundaries? A Shifted-Bar Stress Test in MQL5
The framework evaluates boundary sensitivity by rebuilding shifted target-timeframe bars from completed M1 sources and running one Strategy Tester pass for each boundary offset. It includes a ShiftedBarBuilder, a demonstration EA, and a deterministic self-test. An XAUUSD M15 case study reports net profit, Profit Factor, maximum relative equity drawdown, and total trades across all 15 offsets to quantify dependence on the bar grid.
Evolutionary Selection of LLM Agents in MetaTrader 5 Evolutionary Selection of LLM Agents in MetaTrader 5
The article describes the architecture of a trading system consisting of 20 Grok (xAI)-based LLM agents, each of which embodies a unique trading philosophy — from pure momentum to a statistical z-score. The system applies a genetic algorithm directly during trading: every 20 trades, it automatically eliminates weak agents, clones strong ones with a prompt mutation, and publishes a leaderboard on the MetaTrader 5 chart — without interrupting trading and without using any SDK.
Feature Neutralization in MQL5: Orthogonalizing Trading Signals Against Confound Factors Feature Neutralization in MQL5: Orthogonalizing Trading Signals Against Confound Factors
We implement feature neutralization so your signal is evaluated without volatility/trend confounds. The core is a native MQL5 rolling OLS engine using Gauss-Jordan elimination and out-of-sample residual scoring, with no DLLs. An EA and indicator integrate the residual and log diagnostics, while a Python companion validates numerics and runs bar-replay tests. Use it to isolate linear exposure and assess true signal contribution.