preview
Symbol Correlation Monitor with Live Heatmap in MQL5

Symbol Correlation Monitor with Live Heatmap in MQL5

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

Introduction

A trader can hold five positions in five symbols and still not be diversified. If two or three of those symbols tend to move together, a loss on one is very likely to show up as a loss on the others at the same time, which means the account's real risk concentration is much higher than the position count alone suggests. Standard trade reports have nothing to say about this, since they look at each position in isolation.

This matrix measures how closely symbols' price returns move together. It does not measure the portfolio's actual risk, since it does not account for position size, direction, or how much each position would gain or lose per point of movement. Two highly correlated symbols held in opposite directions can reduce risk rather than concentrate it. Treat the matrix as a signal that two symbols are worth a closer look, not as a finished risk calculation.

Pearson correlation answers a simple question: over a recent window, how closely did the returns of two symbols move together? A value near 1.0 means they moved almost in lockstep. A value near -1.0 means one tended to rise when the other fell. A value near zero means no clear linear relationship at all. None of this is exotic mathematics, but correlation between two symbols is not a fixed fact. It drifts, sometimes sharply, as market regimes shift, so a number computed once at the start of the day can already be stale by the afternoon.

This article builds an Expert Advisor that keeps the correlation matrix close to current. It reads the symbols of all open positions, builds rolling return windows from closed bars, computes the pairwise correlation matrix, and draws it as a heatmap on the chart. The matrix recomputes on every new bar and on every change to the open position set, not on every tick. Every symbol pair whose correlation reaches a configurable threshold gets a visible warning border, so a trader glancing at the panel does not need to read every number to see where the risk is concentrated.

Before the implementation: a correlation matrix describes the recent past, not a forecast. A pair that was highly correlated last week can decouple this week, and a pair that looks independent today can start moving together the moment a shared driver, a rate decision, or a risk-off event shows up. This monitor is built to make that drift visible soon after it happens, not to predict when it will happen next. Because every return is measured between closed bars, the matrix always reflects the most recently completed bar, not the instant a correlation shift begins.

Correlation Monitor Architecture

Correlation Monitor Architecture: The Expert Advisor drives the pipeline: symbols are collected, return series are built, then correlations are calculated. The result feeds both the heatmap chart and the summary printer.


Data Model: Return Series and the Correlation Matrix — CorrelationTypes.mqh

Two structures carry this project's data. CSymbolReturnSeries holds one symbol's rolling window of bar-to-bar percentage returns. CCorrelationMatrix holds the finished, square matrix of pairwise correlations across every monitored symbol.

CSymbolReturnSeries is deliberately narrow: a symbol name, a return array, and a count. Nothing else about the symbol, its open positions, its lot size, its current price, belongs here, since nothing downstream needs it. The constructor resets the return array to empty explicitly, so a series that has not yet been built by the reader can never be mistaken for a real result with zero returns in it.

//+------------------------------------------------------------------+
//|                                             CorrelationTypes.mqh |
//+------------------------------------------------------------------+
#ifndef CORRELATIONTYPES_MQH
#define CORRELATIONTYPES_MQH

//+---------------------------------------------------------------------+
//| CSymbolReturnSeries                                                 |
//+---------------------------------------------------------------------+
class CSymbolReturnSeries
  {
public:
   string            m_symbol;    // symbol this series belongs to
   double            m_returns[]; // bar-to-bar percentage returns, most recent first
   int               m_count;     // number of returns currently held
                     CSymbolReturnSeries(void);
  };

//+------------------------------------------------------------------+
//| Constructor                                                      |
//| Initializes an explicit, empty default state so a series that has|
//| not yet been populated can never be mistaken for a real result.  |
//+------------------------------------------------------------------+
CSymbolReturnSeries::CSymbolReturnSeries(void)
  {
   m_symbol = "";
   m_count  = 0;
   ::ArrayResize(m_returns, 0);
  }

CCorrelationMatrix is more involved, because MQL5 does not let a class hold a true two-dimensional array as a member. The matrix is instead stored as one flat array, and Index() converts a (row, column) pair into the offset inside that flat array. A second flat array, m_defined, tracks whether each cell's correlation could actually be computed, since a symbol whose price did not move at all across the window produces a mathematically undefined result rather than a genuine zero, and the two need to stay visibly different. Every accessor on this class trusts the caller to pass a row and column between 0 and Size() minus 1; every call site in this project loops within that range, so no bounds check is performed inside the class itself.

//+-----------------------------------------------------------------------+
//| CCorrelationMatrix                                                    |
//+-----------------------------------------------------------------------+
class CCorrelationMatrix
  {
private:
   string            m_symbols[];
   double            m_values[];
   bool              m_defined[];
   int               m_size;

public:
                     CCorrelationMatrix(void);
                    ~CCorrelationMatrix(void);
   void              Initialize(const string &symbols[], int count);
   int               Index(int row, int col) const;
   void              SetValue(int row, int col, double value, bool is_defined);
   double            GetValue(int row, int col) const;
   bool              IsDefined(int row, int col) const;
   int               Size(void) const;
   string            GetSymbol(int index) const;
  };

//+------------------------------------------------------------------+
//| Constructor                                                      |
//| Starts with an empty, zero-sized matrix until Initialize() is    |
//| called.                                                          |
//+------------------------------------------------------------------+
CCorrelationMatrix::CCorrelationMatrix(void)
  {
   m_size = 0;
   ::ArrayResize(m_symbols, 0);
   ::ArrayResize(m_values, 0);
   ::ArrayResize(m_defined, 0);
  }

//+------------------------------------------------------------------+
//| Destructor                                                       |
//| No resources are owned beyond plain arrays, so no cleanup beyond |
//| default destruction is required.                                 |
//+------------------------------------------------------------------+
CCorrelationMatrix::~CCorrelationMatrix(void)
  {
  }

Initialize(), SetValue(), and GetValue() make the flat-array design usable. Initialize() allocates the value and defined arrays at count * count and resets every cell before any correlation has actually been computed. SetValue() writes one cell, and here it is worth being precise about why the method cannot be const: this has nothing to do with array parameters passed by reference, the rule this project applies elsewhere. SetValue() writes directly into m_values and m_defined, which are the class's own members, and mutating a class's own state always requires a non-const method, independent of any other rule.

//+--------------------------------------------------------------------+
//| Initialize                                                         |
//+--------------------------------------------------------------------+
void CCorrelationMatrix::Initialize(const string &symbols[],const int count)
  {
   m_size = count;
   ::ArrayResize(m_symbols, count);
   for(int i = 0; i < count; i++)
      m_symbols[i] = symbols[i];
   ::ArrayResize(m_values, count * count);
   ::ArrayResize(m_defined, count * count);
   for(int i = 0; i < count * count; i++)
     {
      m_values[i]  = 0.0;
      m_defined[i] = false;
     }
  }
//+------------------------------------------------------------------------+
//| SetValue                                                               |
//+------------------------------------------------------------------------+
void CCorrelationMatrix::SetValue(const int row,const int col,const double value,const bool is_defined)
  {
   int index = Index(row, col);
   m_values[index]  = value;
   m_defined[index] = is_defined;
  }

Index() is the one calculation the rest of the class depends on, and it is exactly the kind of small, self-contained arithmetic where an off-by-one error would first appear. Keeping it in its own method, rather than repeating the row * m_size + col formula wherever a cell is touched, means it only has to be gotten right once, and it can be tested directly against known corner cases.

//+-----------------------------------------------------------------------+
//| Index                                                                 |
//+-----------------------------------------------------------------------+
int CCorrelationMatrix::Index(const int row,const int col) const
  {
   return(row * m_size + col);
  }

GetValue(), IsDefined(), Size(), and GetSymbol() round out the class as simple, const-safe accessors: GetValue() and IsDefined() return one cell's figures, Size() reports how many symbols are in the matrix, and GetSymbol() returns the label for a given row or column index.

//+------------------------------------------------------------------+
//| GetValue                                                         |
//+------------------------------------------------------------------+
double CCorrelationMatrix::GetValue(const int row,const int col) const
  {
   return(m_values[Index(row, col)]);
  }

//+------------------------------------------------------------------+
//| IsDefined                                                        |
//+------------------------------------------------------------------+
bool CCorrelationMatrix::IsDefined(const int row,const int col) const
  {
   return(m_defined[Index(row, col)]);
  }

//+------------------------------------------------------------------+
//| Size                                                             |
//+------------------------------------------------------------------+
int CCorrelationMatrix::Size(void) const
  {
   return(m_size);
  }

//+------------------------------------------------------------------+
//| GetSymbol                                                        |
//+------------------------------------------------------------------+
string CCorrelationMatrix::GetSymbol(const int index) const
  {
   return(m_symbols[index]);
  }


Finding Out Which Symbols Are Actually Open — PositionSymbolCollector.mqh

Before any correlation can be computed, the monitor needs to know which symbols matter right now, and that list changes every time a position opens or closes. CPositionSymbolCollector handles this with two methods: a private search helper and a public collection method.

//+------------------------------------------------------------------+
//|                                      PositionSymbolCollector.mqh |
//+------------------------------------------------------------------+
#ifndef POSITIONSYMBOLCOLLECTOR_MQH
#define POSITIONSYMBOLCOLLECTOR_MQH

//+---------------------------------------------------------------------+
//| CPositionSymbolCollector                                            |
//+---------------------------------------------------------------------+
class CPositionSymbolCollector
  {
private:
   int               FindSymbolIndex(const string &symbols[], int count, const string target_symbol) const;

public:
                     CPositionSymbolCollector(void);
                    ~CPositionSymbolCollector(void);
   bool              Collect(string &symbols_out[]) const;
  };

//+------------------------------------------------------------------+
//| Constructor                                                      |
//| The collector holds no state between calls, so construction      |
//| performs no work beyond default object creation.                 |
//+------------------------------------------------------------------+
CPositionSymbolCollector::CPositionSymbolCollector(void)
  {
  }

//+------------------------------------------------------------------+
//| Destructor                                                       |
//| No resources are owned by this class, so no cleanup is required. |
//+------------------------------------------------------------------+
CPositionSymbolCollector::~CPositionSymbolCollector(void)
  {
  }

FindSymbolIndex() is a linear search that checks whether a symbol has already been added to the list being built. It only reads its array parameter, so it stays a safe const method.

//+---------------------------------------------------------------------+
//| FindSymbolIndex                                                     |
//+---------------------------------------------------------------------+
int CPositionSymbolCollector::FindSymbolIndex(const string &symbols[],const int count,const string target_symbol) const
  {
   for(int i = 0; i < count; i++)
     {
      if(symbols[i] == target_symbol)
         return(i);
     }
   return(-1);
  }

Collect() walks every open position with PositionGetTicket() and PositionGetString(POSITION_SYMBOL), appending a symbol to the output only the first time it appears. A trader holding three EURUSD positions and one GBPUSD position ends up with a two-symbol list, not four, since the correlation matrix should have one row per distinct symbol, not one row per position. An account with no open positions at all is not treated as an error: Collect() simply returns an empty list, and the caller decides what to show on an otherwise empty heatmap.

//+------------------------------------------------------------------------+
//| Collect                                                                |
//+------------------------------------------------------------------------+
bool CPositionSymbolCollector::Collect(string &symbols_out[]) const
  {
//--- start from an empty output buffer regardless of any prior contents
   ::ArrayResize(symbols_out, 0);
   int total_positions = ::PositionsTotal();
   for(int i = 0; i < total_positions; i++)
     {
      ulong ticket = ::PositionGetTicket(i);
      if(ticket == 0)
         continue;
      string symbol = ::PositionGetString(POSITION_SYMBOL);
      //--- only append a symbol the first time it is encountered
      if(FindSymbolIndex(symbols_out, ::ArraySize(symbols_out), symbol) < 0)
        {
         int index = ::ArraySize(symbols_out);
         ::ArrayResize(symbols_out, index + 1);
         symbols_out[index] = symbol;
        }
     }
   return(true);
  }


Turning Bar Prices into Returns — ReturnSeriesReader.mqh

Correlation is computed on returns, not on raw price levels, since two symbols quoted on completely different scales can still move in lockstep percentage-wise. CReturnSeriesReader keeps the terminal-facing step and the arithmetic in separate methods, the same split this project uses everywhere I/O and computation meet.

//+------------------------------------------------------------------+
//|                                           ReturnSeriesReader.mqh |
//+------------------------------------------------------------------+
#ifndef RETURNSERIESREADER_MQH
#define RETURNSERIESREADER_MQH

#include "CorrelationTypes.mqh"

//+---------------------------------------------------------------------+
//| CReturnSeriesReader                                                 |
//+---------------------------------------------------------------------+
class CReturnSeriesReader
  {
public:
                     CReturnSeriesReader(void);
                    ~CReturnSeriesReader(void);
   bool              ReadCloses(const string symbol, ENUM_TIMEFRAMES timeframe, int closes_needed, double &closes_out[]) const;
   bool              ComputeReturns(const double &closes[], int count, double &returns_out[]) const;
   bool              BuildSeries(const string symbol, ENUM_TIMEFRAMES timeframe, int bar_count, CSymbolReturnSeries &series_out) const;
  };

//+-------------------------------------------------------------------+
//| Constructor                                                       |
//| The reader holds no state between calls, so construction performs |
//| no work beyond default object creation.                           |
//+-------------------------------------------------------------------+
CReturnSeriesReader::CReturnSeriesReader(void)
  {
  }

//+------------------------------------------------------------------+
//| Destructor                                                       |
//| No resources are owned by this class, so no cleanup is required. |
//+------------------------------------------------------------------+
CReturnSeriesReader::~CReturnSeriesReader(void)
  {
  }

ReadCloses() is the only method here that touches the terminal. It requests bars starting one position back from the current bar, using CopyClose() with a start shift of 1, which deliberately skips the bar that is still forming. Without that skip, the newest correlation figure would keep shifting on every incoming tick rather than only when a new bar actually closes, which defeats the point of a rolling, bar-based monitor. CopyClose() always fills its destination array oldest bar first, regardless of how the array was indexed before the call, so ReadCloses() calls ArraySetAsSeries() on the result to make index 0 reliably mean the newest bar, which every method built on top of this one assumes. Requesting fewer bars than are actually available is reported as a failure, which happens naturally for a symbol with a short history.

//+------------------------------------------------------------------------+
//| ReadCloses                                                             |
//+------------------------------------------------------------------------+
bool CReturnSeriesReader::ReadCloses(const string symbol,const ENUM_TIMEFRAMES timeframe,
                                     const int closes_needed,double &closes_out[]) const
  {
   int copied = ::CopyClose(symbol, timeframe, 1, closes_needed, closes_out);
   if(copied < closes_needed)
      return(false);
//--- CopyClose() always fills the array oldest first; ArraySetAsSeries() makes
//--- index 0 refer to the newest copied bar, matching what the rest of this
//--- class assumes.
   ::ArraySetAsSeries(closes_out, true);
   return(true);
  }

ComputeReturns() is the pure function that turns those closes into percentage returns. The "closes" array follows the terminal's own series convention, where index 0 is the most recent bar and later indices go further back, so returns_out[0] ends up as the most recent return, and it needs at least two closes to produce one return. A zero-priced close in the denominator is treated as invalid data rather than allowed to divide by zero, which could otherwise happen on corrupted history or a placeholder value.

//+------------------------------------------------------------------------+
//| ComputeReturns                                                         |
//+------------------------------------------------------------------------+
bool CReturnSeriesReader::ComputeReturns(const double &closes[],const int count,double &returns_out[]) const
  {
   if(count < 2)
      return(false);
   ::ArrayResize(returns_out, count - 1);
   for(int i = 0; i < count - 1; i++)
     {
      if(closes[i + 1] == 0.0)
         return(false);
      returns_out[i] = (closes[i] - closes[i + 1]) / closes[i + 1];
     }
   return(true);
  }

BuildSeries() orchestrates the two, requesting exactly bar_count + 1 closes so that bar_count returns can be computed from them, then copying the result into the output series with ArrayCopy(). This is a freshly constructed CSymbolReturnSeries on every call, so its return array always starts empty before the copy, which matters for a reason that comes up again later in this article.

//+------------------------------------------------------------------------+
//| BuildSeries                                                            |
//+------------------------------------------------------------------------+
bool CReturnSeriesReader::BuildSeries(const string symbol,const ENUM_TIMEFRAMES timeframe,
                                       const int bar_count,CSymbolReturnSeries &series_out) const
  {
   double closes[];
   if(!ReadCloses(symbol, timeframe, bar_count + 1, closes))
      return(false);

   double returns[];
   if(!ComputeReturns(closes, ::ArraySize(closes), returns))
      return(false);

   series_out.m_symbol = symbol;
   ::ArrayCopy(series_out.m_returns, returns);
   series_out.m_count = ::ArraySize(returns);
   return(true);
  }


Computing Pearson Correlation and the Full Matrix — PearsonCorrelationCalculator.mqh

CPearsonCorrelationCalculator is where the actual statistics live, and both of its methods are pure functions with no dependency on a live account, which is what makes this class directly testable.

//+------------------------------------------------------------------+
//|                                 PearsonCorrelationCalculator.mqh |
//+------------------------------------------------------------------+
#ifndef PEARSONCORRELATIONCALCULATOR_MQH
#define PEARSONCORRELATIONCALCULATOR_MQH

#include "CorrelationTypes.mqh"

//+--------------------------------------------------------------------+
//| CPearsonCorrelationCalculator                                      |
//+--------------------------------------------------------------------+
class CPearsonCorrelationCalculator
  {
public:
                     CPearsonCorrelationCalculator(void);
                    ~CPearsonCorrelationCalculator(void);
   bool              ComputePairCorrelation(const double &series_a[], const double &series_b[], int count, double &correlation_out) const;
   bool              BuildMatrix(const CSymbolReturnSeries &series[], int count, CCorrelationMatrix &matrix_out) const;
  };

//+------------------------------------------------------------------+
//| Constructor                                                      |
//| The calculator holds no state between calls, so construction     |
//| performs no work beyond default object creation.                 |
//+------------------------------------------------------------------+
CPearsonCorrelationCalculator::CPearsonCorrelationCalculator(void)
  {
  }

//+------------------------------------------------------------------+
//| Destructor                                                       |
//| No resources are owned by this class, so no cleanup is required. |
//+------------------------------------------------------------------+
CPearsonCorrelationCalculator::~CPearsonCorrelationCalculator(void)
  {
  }

ComputePairCorrelation() implements the standard formula: covariance divided by the product of both series' standard deviations. A series with zero variance, every return in it identical, most commonly a symbol whose price simply did not move across the window, makes that denominator zero and the ratio mathematically undefined. Rather than defaulting to a correlation of zero, which could be misread as a genuine finding of "no relationship," this method reports the result as undefined through its return value. When a result is defined, it is also clamped to the theoretical [-1, 1] range, since accumulated floating-point rounding across many terms can occasionally push a result fractionally outside that range even when the true correlation is exactly 1.0 or -1.0.

//+---------------------------------------------------------------------------+
//| ComputePairCorrelation                                                    |
//+---------------------------------------------------------------------------+
bool CPearsonCorrelationCalculator::ComputePairCorrelation(const double &series_a[],const double &series_b[],
      const int count,double &correlation_out) const
  {
   correlation_out = 0.0;
   if(count < 2)
      return(false);

//--- compute both series' means
   double sum_a = 0.0, sum_b = 0.0;
   for(int i = 0; i < count; i++)
     {
      sum_a += series_a[i];
      sum_b += series_b[i];
     }
   double mean_a = sum_a / count;
   double mean_b = sum_b / count;

//--- accumulate covariance and both variances in a single pass
   double covariance = 0.0;
   double variance_a = 0.0;
   double variance_b = 0.0;
   for(int i = 0; i < count; i++)
     {
      double deviation_a = series_a[i] - mean_a;
      double deviation_b = series_b[i] - mean_b;
      covariance += deviation_a * deviation_b;
      variance_a += deviation_a * deviation_a;
      variance_b += deviation_b * deviation_b;
     }

//--- a zero-variance series makes the denominator zero and the ratio undefined
   double denominator = ::MathSqrt(variance_a * variance_b);
   if(denominator == 0.0)
      return(false);

   double correlation = covariance / denominator;
//--- clamp away any floating-point overshoot past the theoretical [-1, 1] range
   if(correlation > 1.0)
      correlation = 1.0;
   if(correlation < -1.0)
      correlation = -1.0;

   correlation_out = correlation;
   return(true);
  }

BuildMatrix() assembles the full matrix from an array of return series. It only computes the upper triangle, including the diagonal, and mirrors each result into its transposed cell, since correlation between A and B is identical to correlation between B and A by definition. The diagonal, a symbol against itself, is computed with the exact same ComputePairCorrelation() call as every other pair rather than being hardcoded to 1.0. That choice keeps the zero-variance rule in one place: a symbol whose returns never moved is exactly as undefined against itself as it is against any other symbol, and there is no need for a second, separate rule to say so.

//+---------------------------------------------------------------------------+
//| BuildMatrix                                                               |
//+---------------------------------------------------------------------------+
bool CPearsonCorrelationCalculator::BuildMatrix(const CSymbolReturnSeries &series[],const int count,
      CCorrelationMatrix &matrix_out) const
  {
   if(count <= 0)
      return(false);

   string symbols[];
   ::ArrayResize(symbols, count);
   for(int i = 0; i < count; i++)
      symbols[i] = series[i].m_symbol;
   matrix_out.Initialize(symbols, count);

   for(int row = 0; row < count; row++)
     {
      for(int col = row; col < count; col++)
        {
         int shared_count = ::MathMin(series[row].m_count, series[col].m_count);
         double correlation = 0.0;
         bool   is_defined  = ComputePairCorrelation(series[row].m_returns, series[col].m_returns, shared_count, correlation);
         matrix_out.SetValue(row, col, correlation, is_defined);
         if(col != row)
            matrix_out.SetValue(col, row, correlation, is_defined);
        }
     }
   return(true);
  }


Rendering the Live Heatmap with CCanvas — CorrelationHeatmapChart.mqh

CCorrelationHeatmapChart owns a CCanvas instance and draws the finished matrix as a grid of colored, labeled cells. Its constructor stores the object name and default cell geometry, and the canvas itself is created lazily on the first call to Draw().

//+------------------------------------------------------------------+
//|                                      CorrelationHeatmapChart.mqh |
//+------------------------------------------------------------------+
#ifndef CORRELATIONHEATMAPCHART_MQH
#define CORRELATIONHEATMAPCHART_MQH

#include <Canvas\Canvas.mqh>
#include "CorrelationTypes.mqh"

//--- palette used by the heatmap, kept at file scope
const color CORR_COLOR_BACKGROUND = clrWhite;
const color CORR_COLOR_TEXT       = clrBlack;
const color CORR_COLOR_BORDER     = C'190,190,190';
const color CORR_COLOR_NEUTRAL    = C'250,250,250';
const color CORR_COLOR_POSITIVE   = C'190,60,20';
const color CORR_COLOR_NEGATIVE   = C'20,90,170';
const color CORR_COLOR_DIAGONAL   = C'225,225,225';
const color CORR_COLOR_UNDEFINED  = C'248,248,248';
const color CORR_COLOR_WARNING    = C'255,140,0';

//+--------------------------------------------------------------------------+
//| CCorrelationHeatmapChart                                                 |
//| Owns a CCanvas-backed chart panel and renders a square heatmap of every  |
//| symbol pair's Pearson correlation, with the numeric value printed inside |
//| each cell. Unlike a metric whose color scale is normalized against       |
//| whatever happens to be in the data, correlation already has a fixed,     |
//| universal range of -1 to 1, so this chart always maps that same fixed    |
//| range to color rather than rescaling it per corr_matrix, which keeps a   |
//| cell's color meaning identical from one redraw to the next.              |
//+--------------------------------------------------------------------------+
class CCorrelationHeatmapChart
  {
private:
   CCanvas           m_canvas;
   string            m_object_name;
   bool              m_canvas_created;
   int               m_created_width;
   int               m_created_height;
   int               m_left_margin;
   int               m_top_margin;
   int               m_cell_size;

   color             CorrelationColor(double value, bool is_defined) const;
   void              DrawLabels(const CCorrelationMatrix &corr_matrix);
   void              DrawCell(int row, int col, const CCorrelationMatrix &corr_matrix, double warning_threshold);

public:
                     CCorrelationHeatmapChart(const string object_name);
                    ~CCorrelationHeatmapChart(void);
   bool              Draw(const CCorrelationMatrix &corr_matrix, double warning_threshold, int x, int y, int width, int height);
   void              Clear(void);
  };

//+---------------------------------------------------------------------+
//| Constructor                                                         |
//| Stores the chart object name and establishes the default layout     |
//| geometry. The canvas is created lazily on the first call to Draw(), |
//| so construction never touches the chart.                            |
//+---------------------------------------------------------------------+
CCorrelationHeatmapChart::CCorrelationHeatmapChart(const string object_name)
  {
   m_object_name    = object_name;
   m_canvas_created = false;
   m_created_width  = 0;
   m_created_height = 0;
   m_left_margin    = 90;
   m_top_margin     = 40;
   m_cell_size      = 60;
  }

//+------------------------------------------------------------------------+
//| Destructor                                                             |
//| Intentionally empty. Unlike a script that runs once and finishes,      |
//| this chart is meant to be redrawn repeatedly for as long as the        |
//| Expert Advisor that owns it stays attached to the chart, so nothing    |
//| here should tear the panel down automatically. Clear() is the explicit,|
//| caller-driven way to remove it, normally called once, from OnDeinit(). |
//+------------------------------------------------------------------------+
CCorrelationHeatmapChart::~CCorrelationHeatmapChart(void)
  {
  }

//+------------------------------------------------------------------+
//| Clear                                                            |
//| Explicitly removes the chart panel and releases the underlying   |
//| graphical resource.                                              |
//+------------------------------------------------------------------+
void CCorrelationHeatmapChart::Clear(void)
  {
   if(m_canvas_created)
     {
      m_canvas.Destroy();
      m_canvas_created = false;
      m_created_width  = 0;
      m_created_height = 0;
     }
  }

The destructor deserves a specific explanation here, because this project's chart panel behaves a little differently from a one-shot script's chart panel. An Expert Advisor stays attached to a chart and calls Draw() again on every new bar, so the panel needs to persist across many calls. Clear() handles teardown instead, called once from OnDeinit() when the Expert Advisor is actually removed. That is the one moment removing the panel is genuinely correct.

CorrelationColor() maps a value to color using correlation's fixed, universal range of -1 to 1, not the largest value in this particular matrix. A 0.8 means the same shade of orange every time the heatmap redraws, which keeps color comparable across bars and screenshots. Zero gets its own branch and renders as flat neutral, so it is never nudged toward red or blue by floating-point noise. An undefined cell gets its own dedicated color too, so a pair that could not be scored never looks the same as a pair that scored at exactly zero.

//+-----------------------------------------------------------------------------+
//| CorrelationColor                                                            |
//+-----------------------------------------------------------------------------+
color CCorrelationHeatmapChart::CorrelationColor(const double value,const bool is_defined) const
  {
   if(!is_defined)
      return(CORR_COLOR_UNDEFINED);
   if(value == 0.0)
      return(CORR_COLOR_NEUTRAL);

   double intensity = ::MathAbs(value); // the scale is fixed to [-1, 1], so the value itself is the intensity
   int    from_r    = (int)(CORR_COLOR_NEUTRAL & 0xFF);
   int    from_g    = (int)((CORR_COLOR_NEUTRAL >> 8) & 0xFF);
   int    from_b    = (int)((CORR_COLOR_NEUTRAL >> 16) & 0xFF);
   color  target    = (value > 0.0) ? CORR_COLOR_POSITIVE : CORR_COLOR_NEGATIVE;
   int    to_r      = (int)(target & 0xFF);
   int    to_g      = (int)((target >> 8) & 0xFF);
   int    to_b      = (int)((target >> 16) & 0xFF);

   int out_r = (int)::MathRound(from_r + (to_r - from_r) * intensity);
   int out_g = (int)::MathRound(from_g + (to_g - from_g) * intensity);
   int out_b = (int)::MathRound(from_b + (to_b - from_b) * intensity);
   return((color)(out_r | (out_g << 8) | (out_b << 16)));
  }

DrawLabels() places each symbol name above its column and to the left of its row. Every label is measured with TextGetSize() first, so a row label can be right-aligned against the grid and a column label centered above its column, regardless of name length.

//+------------------------------------------------------------------------+
//| DrawLabels                                                             |
//+------------------------------------------------------------------------+
void CCorrelationHeatmapChart::DrawLabels(const CCorrelationMatrix &corr_matrix)
  {
   int size = corr_matrix.Size();
   m_canvas.FontSet("Arial", 10, FW_BOLD, 0);
   ::TextSetFont("Arial", 10, FW_BOLD, 0);
   for(int i = 0; i < size; i++)
     {
      string label = corr_matrix.GetSymbol(i);
      uint label_w = 0, label_h = 0;
      ::TextGetSize(label, label_w, label_h);

      //--- row label, right-aligned against the left edge of the grid
      int row_y = m_top_margin + i * m_cell_size + (m_cell_size - (int)label_h) / 2;
      m_canvas.TextOut(m_left_margin - (int)label_w - 8, row_y, label, ::ColorToARGB(CORR_COLOR_TEXT, 255));

      //--- column label, centered above its own column
      int col_x = m_left_margin + i * m_cell_size + (m_cell_size - (int)label_w) / 2;
      m_canvas.TextOut(col_x, m_top_margin - (int)label_h - 6, label, ::ColorToARGB(CORR_COLOR_TEXT, 255));
     }
  }

DrawCell() fills and outlines one cell, prints its correlation value inside it, and adds a warning border when appropriate. The diagonal is excluded from that check: a symbol's correlation with itself is trivially 1.0, and flagging it would only distract from the real concentration-risk pairs. Since CCanvas::Rectangle() draws only a single-pixel outline, the thick warning border is built from three concentric rectangles, one pixel apart, which reads as bold without needing a dedicated thick-line primitive. The warning border responds to the size of the correlation, not its sign, so a strongly negative pair is flagged exactly like a strongly positive one. Both describe symbols that move together in a reliable, measurable pattern. Whether that pattern increases or reduces the trader's actual risk still depends on the direction and size of each position, which this chart does not know.

//+----------------------------------------------------------------------+
//| DrawCell                                                             |
//+----------------------------------------------------------------------+
void CCorrelationHeatmapChart::DrawCell(const int row,const int col,const CCorrelationMatrix &corr_matrix,
                                        const double warning_threshold)
  {
   int cell_x = m_left_margin + col * m_cell_size;
   int cell_y = m_top_margin  + row * m_cell_size;
   bool   is_diagonal = (row == col);
   bool   is_defined  = corr_matrix.IsDefined(row, col);
   double value       = corr_matrix.GetValue(row, col);

   color fill_color = is_diagonal ? CORR_COLOR_DIAGONAL : CorrelationColor(value, is_defined);
   m_canvas.FillRectangle(cell_x, cell_y, cell_x + m_cell_size, cell_y + m_cell_size, ::ColorToARGB(fill_color, 255));
   m_canvas.Rectangle(cell_x, cell_y, cell_x + m_cell_size, cell_y + m_cell_size, ::ColorToARGB(CORR_COLOR_BORDER, 255));

//--- a thick warning border on any off-diagonal cell at or beyond the threshold
   if(!is_diagonal && is_defined && ::MathAbs(value) >= warning_threshold)
     {
      for(int inset = 0; inset < 3; inset++)
         m_canvas.Rectangle(cell_x + inset, cell_y + inset, cell_x + m_cell_size - inset, cell_y + m_cell_size - inset,
                            ::ColorToARGB(CORR_COLOR_WARNING, 255));
     }

//--- numeric value, or an explicit "N/A" when the cell is undefined
   string value_text = is_defined ? ::DoubleToString(value, 2) : "N/A";
   m_canvas.FontSet("Arial", 11, FW_BOLD, 0);
   ::TextSetFont("Arial", 11, FW_BOLD, 0);
   uint text_w = 0, text_h = 0;
   ::TextGetSize(value_text, text_w, text_h);
   int text_x = cell_x + (m_cell_size - (int)text_w) / 2;
   int text_y = cell_y + (m_cell_size - (int)text_h) / 2;
   m_canvas.TextOut(text_x, text_y, value_text, ::ColorToARGB(CORR_COLOR_TEXT, 255));
  }

Draw() ties everything together: it creates the canvas on first use, or recreates it if the requested size has changed since the last call, handles an empty matrix with an explanatory message, then draws the labels and every cell. The resize check exists because the matrix grows and shrinks as positions open and close, and a canvas sized for the first draw would otherwise clip part of the grid once the symbol count outgrows it.

//+-------------------------------------------------------------+
//| Draw                                                        |
//+-------------------------------------------------------------+
bool CCorrelationHeatmapChart::Draw(const CCorrelationMatrix &corr_matrix,const double warning_threshold,
                                    const int x,const int y,const int width,const int height)
  {
//--- (re)create the canvas whenever it has not been created yet, or when the
//--- requested size no longer matches the size it was created at. The matrix
//--- can grow as positions open, so a fixed canvas size would silently clip
//--- the grid once the symbol count outgrows it.
   if(!m_canvas_created || width != m_created_width || height != m_created_height)
     {
      if(m_canvas_created)
         m_canvas.Destroy();
      if(!m_canvas.CreateBitmapLabel(m_object_name, x, y, width, height, COLOR_FORMAT_ARGB_NORMALIZE))
        {
         ::Print("CCorrelationHeatmapChart: failed to create canvas for object '", m_object_name, "'.");
         m_canvas_created = false;
         return(false);
        }
      m_canvas_created = true;
      m_created_width  = width;
      m_created_height = height;
     }
   m_canvas.Erase(::ColorToARGB(CORR_COLOR_BACKGROUND, 255));

   int size = corr_matrix.Size();
   if(size <= 0)
     {
      m_canvas.FontSet("Arial", 11, 0, 0);
      ::TextSetFont("Arial", 11, 0, 0);
      string message = "No open positions to correlate.";
      m_canvas.TextOut(m_left_margin, m_top_margin, message, ::ColorToARGB(CORR_COLOR_TEXT, 255));
      m_canvas.Update(true);
      return(true);
     }

   DrawLabels(corr_matrix);
   for(int row = 0; row < size; row++)
     {
      for(int col = 0; col < size; col++)
         DrawCell(row, col, corr_matrix, warning_threshold);
     }

   m_canvas.Update(true);
   return(true);
  }


A Numerical Report to the Experts Tab — CorrelationSummaryPrinter.mqh

CCorrelationSummaryPrinter backs the heatmap with an exact text report: every pair at or beyond the warning threshold, how many pairs could not be scored, and the single highest and single lowest defined correlation in the matrix. It only scans the upper triangle, excluding the diagonal, since every pair would otherwise be counted twice, and a symbol's correlation with itself carries no analytical meaning worth reporting.

//+------------------------------------------------------------------+
//|                                    CorrelationSummaryPrinter.mqh |
//+------------------------------------------------------------------+
#ifndef CORRELATIONSUMMARYPRINTER_MQH
#define CORRELATIONSUMMARYPRINTER_MQH

#include "CorrelationTypes.mqh"

//+--------------------------------------------------------------------+
//| CCorrelationSummaryPrinter                                         |
//| Complements the heatmap with an exact numerical report printed to  |
//| the Experts tab: which symbol pairs exceed the warning threshold,  |
//| which pairs could not be scored at all, and the single highest and |
//| single lowest defined correlation in the corr_matrix.              |
//+--------------------------------------------------------------------+
class CCorrelationSummaryPrinter
  {
public:
                     CCorrelationSummaryPrinter(void);
                    ~CCorrelationSummaryPrinter(void);
   void              Print(const CCorrelationMatrix &corr_matrix, double warning_threshold) const;
  };

//+-------------------------------------------------------------------+
//| Constructor                                                       |
//| The printer holds no state between calls, so construction performs|
//| no work beyond default object creation.                           |
//+-------------------------------------------------------------------+
CCorrelationSummaryPrinter::CCorrelationSummaryPrinter(void)
  {
  }

//+------------------------------------------------------------------+
//| Destructor                                                       |
//| No resources are owned by this class, so no cleanup is required. |
//+------------------------------------------------------------------+
CCorrelationSummaryPrinter::~CCorrelationSummaryPrinter(void)
  {
  }

//+--------------------------------------------------------------------------+
//| Print                                                                    |
//| Scans only the upper triangle of the corr_matrix, excluding the diagonal,|
//| since each pair otherwise appears twice and a symbol's correlation       |
//| with itself carries no analytical meaning. Every pair at or beyond the   |
//| warning threshold is printed as its own line; every undefined pair is    |
//| counted separately rather than silently ignored. When the corr_matrix has|
//| fewer than two symbols there is nothing to correlate, so that case is    |
//| reported explicitly instead of falling through to an empty best/worst    |
//| search.                                                                  |
//+--------------------------------------------------------------------------+
void CCorrelationSummaryPrinter::Print(const CCorrelationMatrix &corr_matrix,const double warning_threshold) const
  {
   int size = corr_matrix.Size();
   ::Print("Symbol Correlation Summary");
   ::PrintFormat("Symbols monitored: %d", size);

   if(size < 2)
     {
      ::Print("Fewer than two symbols are open, so no pair can be correlated.");
      return;
     }

   int    warning_count   = 0;
   int    undefined_count = 0;
   int    best_row        = -1, best_col = -1;
   int    worst_row       = -1, worst_col = -1;
   double best_value      = 0.0, worst_value = 0.0;
   bool   best_found      = false, worst_found = false;

   for(int row = 0; row < size; row++)
     {
      for(int col = row + 1; col < size; col++)
        {
         if(!corr_matrix.IsDefined(row, col))
           {
            undefined_count++;
            continue;
           }
         double value = corr_matrix.GetValue(row, col);

         if(::MathAbs(value) >= warning_threshold)
           {
            warning_count++;
            ::PrintFormat("WARNING: %s <-> %s   r = %.2f   exceeds the %.2f threshold",
                          corr_matrix.GetSymbol(row), corr_matrix.GetSymbol(col), value, warning_threshold);
           }

         if(!best_found || value > best_value)
           {
            best_found = true;
            best_value = value;
            best_row   = row;
            best_col   = col;
           }
         if(!worst_found || value < worst_value)
           {
            worst_found = true;
            worst_value = value;
            worst_row   = row;
            worst_col   = col;
           }
        }
     }

   ::PrintFormat("Pairs at or beyond the warning threshold: %d", warning_count);
   if(undefined_count > 0)
      ::PrintFormat("Pairs that could not be scored (flat returns over the window): %d", undefined_count);

   if(best_found)
      ::PrintFormat("Highest correlation: %s <-> %s   r = %.2f", corr_matrix.GetSymbol(best_row), corr_matrix.GetSymbol(best_col), best_value);
   if(worst_found)
      ::PrintFormat("Lowest correlation:  %s <-> %s   r = %.2f", corr_matrix.GetSymbol(worst_row), corr_matrix.GetSymbol(worst_col), worst_value);
   if(!best_found)
      ::Print("No pair in the corr_matrix produced a defined correlation.");
  }

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


Building the Live Expert Advisor — SymbolCorrelationMonitor.mq5

SymbolCorrelationMonitor.mq5 wires the five preceding components together, and it is where this project's structure genuinely departs from a script that runs once and exits. The heatmap needs to stay on the chart and keep refreshing, bar after bar, for as long as the Expert Advisor is attached, which means the chart object itself has to persist across many calls rather than being built fresh inside a function that runs repeatedly.

That is why g_heatmap_chart is declared at global scope, constructed exactly once when the Expert Advisor loads, rather than as a local variable inside the function that redraws it.

//+------------------------------------------------------------------+
//|                                    SymbolCorrelationMonitor.mq5  |
//+------------------------------------------------------------------+
#property description "Computes a rolling Pearson correlation matrix across the "
#property description "symbols of every currently open position and renders it as "
#property description "a live CCanvas heatmap, redrawn on every new bar."
#property strict

#include <SymbolCorrelationMonitor/CorrelationTypes.mqh>
#include <SymbolCorrelationMonitor/PositionSymbolCollector.mqh>
#include <SymbolCorrelationMonitor/ReturnSeriesReader.mqh>
#include <SymbolCorrelationMonitor/PearsonCorrelationCalculator.mqh>
#include <SymbolCorrelationMonitor/CorrelationHeatmapChart.mqh>
#include <SymbolCorrelationMonitor/CorrelationSummaryPrinter.mqh>

input ENUM_TIMEFRAMES InpTimeframe             = PERIOD_H1;  // timeframe the rolling return window is measured on
input int             InpBarCount              = 50;         // number of bar-to-bar returns in the rolling window
input double          InpCorrelationThreshold  = 0.8;        // absolute correlation at or above which a pair is flagged
input int             InpMaxSymbols            = 12;         // safety cap on distinct open-position symbols handled

//--- the heatmap panel is declared at global scope, not created fresh on every
//--- call, since an Expert Advisor's canvas must persist and simply be redrawn
//--- across many bars rather than recreated the way a one-shot script would
CCorrelationHeatmapChart g_heatmap_chart("CorrelationMonitor_Heatmap");
datetime                 g_last_bar_time = 0;
string                   g_last_symbols[]; // the open-position symbol set as of the last update

SymbolListsEqual() compares two symbol lists as sets, not as ordered sequences, since the terminal does not guarantee that position order stays stable between calls even when the underlying set of symbols has not changed.

//+-------------------------------------------------------------------------+
//| SymbolListsEqual                                                        |
//+-------------------------------------------------------------------------+
bool SymbolListsEqual(const string &list_a[],const string &list_b[])
  {
   int size_a = ::ArraySize(list_a);
   int size_b = ::ArraySize(list_b);
   if(size_a != size_b)
      return(false);
   for(int i = 0; i < size_a; i++)
     {
      bool found = false;
      for(int j = 0; j < size_b; j++)
        {
         if(list_a[i] == list_b[j])
           {
            found = true;
            break;
           }
        }
      if(!found)
         return(false);
     }
   return(true);
  }

UpdateAndRender() calls Draw() on that same instance every time it runs. Draw() creates the underlying bitmap object the first time it is called, and repaints that same object on later calls, recreating it only when the number of monitored symbols changes enough to need a different panel size.

//+-------------------------------------------------------------------------+
//| UpdateAndRender                                                         |
//+-------------------------------------------------------------------------+
void UpdateAndRender(void)
  {
   CPositionSymbolCollector collector;
   string symbols[];
   collector.Collect(symbols);

   int total_symbols = ::ArraySize(symbols);
   if(total_symbols > InpMaxSymbols)
     {
      ::PrintFormat("SymbolCorrelationMonitor: %d open-position symbols found, truncating to the first %d.",
                    total_symbols, InpMaxSymbols);
      ::ArrayResize(symbols, InpMaxSymbols);
      total_symbols = InpMaxSymbols;
     }

//--- build a return series per symbol, keeping only the symbols that succeed
   CReturnSeriesReader   reader;
   CSymbolReturnSeries   built_series[];
   for(int i = 0; i < total_symbols; i++)
     {
      CSymbolReturnSeries candidate;
      if(reader.BuildSeries(symbols[i], InpTimeframe, InpBarCount, candidate))
        {
         int index = ::ArraySize(built_series);
         ::ArrayResize(built_series, index + 1);
         built_series[index].m_symbol = candidate.m_symbol;
         built_series[index].m_count  = candidate.m_count;
         ::ArrayCopy(built_series[index].m_returns, candidate.m_returns);
        }
      else
        {
         ::PrintFormat("SymbolCorrelationMonitor: skipping %s, insufficient bar history for a %d-bar window.",
                       symbols[i], InpBarCount);
        }
     }

   CPearsonCorrelationCalculator calculator;
   CCorrelationMatrix            corr_matrix;
   bool matrix_built = calculator.BuildMatrix(built_series, ::ArraySize(built_series), corr_matrix);

//--- only a genuine data failure short-circuits the update: symbols were open,
//--- but none of them produced a usable return series. Zero open positions is
//--- not a failure, and falls through to the normal empty-matrix handling in
//--- the printer and the chart window.
   if(!matrix_built && total_symbols > 0)
     {
      ::PrintFormat("SymbolCorrelationMonitor: %d open-position symbols found, but no return series could be built from them.",
                    total_symbols);
      return;
     }

   CCorrelationSummaryPrinter printer;
   printer.Print(corr_matrix, InpCorrelationThreshold);

   int panel_size = 90 + corr_matrix.Size() * 60 + 40;
   g_heatmap_chart.Draw(corr_matrix, InpCorrelationThreshold, 20, 20, panel_size, panel_size);
   ::ChartRedraw(0);
  }

OnInit() first rejects a handful of input combinations that cannot produce a meaningful result: a bar count below two, a correlation threshold outside zero to one, or a maximum symbol count below one. Once the inputs pass that check, OnInit() calls UpdateAndRender() immediately, so the panel appears the moment the Expert Advisor attaches rather than waiting for the first new bar. It then records both the bar this first render was based on and the current open-position symbol set, so the first OnTick() has something genuine to compare against rather than an empty placeholder. OnDeinit() calls Clear() once, the deliberate moment this project actually tears the panel down.

//+---------------------------------------------------------------------------+
//| Expert initialization function                                            |
//+---------------------------------------------------------------------------+
int OnInit(void)
  {
//--- reject configurations that cannot produce a meaningful result
   if(InpBarCount < 2)
     {
      ::Print("SymbolCorrelationMonitor: InpBarCount must be at least 2.");
      return(INIT_PARAMETERS_INCORRECT);
     }
   if(InpCorrelationThreshold < 0.0 || InpCorrelationThreshold > 1.0)
     {
      ::Print("SymbolCorrelationMonitor: InpCorrelationThreshold must be between 0.0 and 1.0.");
      return(INIT_PARAMETERS_INCORRECT);
     }
   if(InpMaxSymbols < 1)
     {
      ::Print("SymbolCorrelationMonitor: InpMaxSymbols must be at least 1.");
      return(INIT_PARAMETERS_INCORRECT);
     }

   ::ArrayResize(g_last_symbols, 0);
   UpdateAndRender();
//--- record the bar this initial render was based on, so the first OnTick()
//--- after attaching does not mistake a real bar time for something different
//--- from the uninitialized zero and immediately rerun the whole pipeline a
//--- second time for no actual reason.
   g_last_bar_time = ::iTime(_Symbol, InpTimeframe, 0);
   CPositionSymbolCollector collector;
   collector.Collect(g_last_symbols);
   return(INIT_SUCCEEDED);
  }

//+-----------------------------------------------------------------------+
//| Expert deinitialization function                                      |
//+-----------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
   g_heatmap_chart.Clear();
  }

OnTick() is where a bar-only monitor would fall short of what a trader actually needs. Waiting for a new InpTimeframe bar before recomputing anything means a position opened five minutes into an hourly bar would not show up on the heatmap for up to fifty-five minutes, which is not a reasonable delay for something meant to flag concentration risk.

//+-------------------------------------------------------------------------+
//| Expert tick function                                                    |
//+-------------------------------------------------------------------------+
void OnTick(void)
  {
   datetime current_bar_time = ::iTime(_Symbol, InpTimeframe, 0);
   bool     is_new_bar       = (current_bar_time != g_last_bar_time);

   CPositionSymbolCollector collector;
   string current_symbols[];
   collector.Collect(current_symbols);
   bool positions_changed = !SymbolListsEqual(current_symbols, g_last_symbols);

   if(!is_new_bar && !positions_changed)
      return;

   g_last_bar_time = current_bar_time;
//--- ArrayCopy() never shrinks a destination array, only grows it when needed,
//--- so g_last_symbols is reset to empty first; otherwise a symbol count that
//--- just decreased (a position closed) would leave stale trailing entries
//--- behind, which would make every subsequent tick look like a false change
//--- and trigger UpdateAndRender() forever.
   ::ArrayResize(g_last_symbols, 0);
   ::ArrayCopy(g_last_symbols, current_symbols);
   UpdateAndRender();
  }

OnTick() itself checks two independent conditions on every tick: whether a new bar has started, and whether the open-position symbol set has changed since the last update. Either one is enough to trigger a full recompute. Scanning open positions on every tick sounds expensive at first glance, but it is a short loop over a handful of tickets, far cheaper than the return-series and correlation work that only runs when something has actually changed.

Symbol Correlation Heatmap — Period A

Symbol Correlation Heatmap — Period A: Illustrative mock-up, not a live screenshot. Each cell shows the Pearson correlation between two symbols' returns, blue for negative, red for positive, gray near zero. EURUSD and GBPUSD sit at 0.91, flagged with the orange warning border for moving almost in lockstep. USDCHF shows a strong negative correlation to both, meaning it tends to move opposite them.


Symbol Correlation Heatmap — Period B

Symbol Correlation Heatmap — Period B: Illustrative mock-up, not a live screenshot. Same color scale as Period A. EURUSD and GBPUSD have cooled to 0.52, clearing the warning as their relationship weakens. AUDUSD and XAUUSD have risen to 0.85, becoming the new flagged pair, showing the monitor catching a fresh concentration risk as correlations shift.


Verification and Testing — TestCorrelationAnalytics.mq5

Position scanning and bar retrieval both depend on a live account, so neither CPositionSymbolCollector nor CReturnSeriesReader::ReadCloses can be meaningfully unit tested in a headless script. Everything downstream of that boundary, ComputeReturns(), the full Pearson correlation calculator, and the matrix's own indexing, is a pure function over plain data, and gets exercised directly with synthetic prices and synthetic return series.

//+------------------------------------------------------------------+
//|                                     TestCorrelationAnalytics.mq5 |
//+------------------------------------------------------------------+
#property description "Verifies return computation, Pearson correlation, and matrix "
#property description "index mapping using synthetic price and return data, "
#property description "independent of any live account or open positions."
#property script_show_inputs

#include <SymbolCorrelationMonitor/CorrelationTypes.mqh>
#include <SymbolCorrelationMonitor/ReturnSeriesReader.mqh>
#include <SymbolCorrelationMonitor/PearsonCorrelationCalculator.mqh>

//--- assertion counters, updated by the ASSERT macro used throughout this script
int g_assertion_passes   = 0;
int g_assertion_failures = 0;

//+---------------------------------------------------------- ---------+
//| ASSERT                                                             |
//| Records a pass or a failure for one checked condition and prints a |
//| diagnostic message, including the source line number, whenever the |
//| condition is false.                                                |
//+--------------------------------------------------------------------+
#define ASSERT(condition, message)                                          \
   if(!(condition))                                                         \
     {                                                                      \
      ::PrintFormat("ASSERTION FAILED: %s (line %d)", (message), __LINE__); \
      g_assertion_failures++;                                               \
     }                                                                      \
   else                                                                     \
     {                                                                      \
      g_assertion_passes++;                                                 \
     }

The first cluster of tests covers ComputeReturns(): a small synthetic close sequence checks the returns against a hand-computed expectation, a single close checks that the method fails rather than returning something invalid, and a zero-priced close checks that the method fails rather than dividing by zero.

//+------------------------------------------------------------------------+
//| TestComputeReturnsBasic                                                |
//| Verifies that ComputeReturns() converts a small synthetic close series |
//| into the expected bar-to-bar percentage returns, with index 0 the most |
//| recent return.                                                         |
//+------------------------------------------------------------------------+
void TestComputeReturnsBasic(void)
  {
   double closes[];
   ArrayResize(closes, 3);
   closes[0] = 110.0; // most recent close
   closes[1] = 100.0;
   closes[2] = 95.0;  // oldest close

   CReturnSeriesReader reader;
   double returns[];
   bool ok = reader.ComputeReturns(closes, ArraySize(closes), returns);

   ASSERT(ok, "ComputeReturns must succeed for at least two closes.");
   ASSERT(ArraySize(returns) == 2, "Three closes must produce exactly two returns.");
   ASSERT(MathAbs(returns[0] - 0.10) < 0.0001, "The newest return must equal (110 - 100) / 100.");
   ASSERT(MathAbs(returns[1] - (5.0 / 95.0)) < 0.0001, "The older return must equal (100 - 95) / 95.");
  }

//+------------------------------------------------------------------------+
//| TestComputeReturnsInsufficientData                                     |
//| Verifies that a single close, with no older close to compare against,  |
//| is reported as a failure rather than an empty or invalid return.       |
//+------------------------------------------------------------------------+
void TestComputeReturnsInsufficientData(void)
  {
   double closes[];
   ArrayResize(closes, 1);
   closes[0] = 100.0;

   CReturnSeriesReader reader;
   double returns[];
   bool ok = reader.ComputeReturns(closes, ArraySize(closes), returns);
   ASSERT(!ok, "ComputeReturns must fail when fewer than two closes are supplied.");
  }

//+----------------------------------------------------------------------+
//| TestComputeReturnsZeroDivision                                       |
//| Verifies that a zero-priced older close is treated as invalid data   |
//| rather than allowed to divide by zero.                               |
//+----------------------------------------------------------------------+
void TestComputeReturnsZeroDivision(void)
  {
   double closes[];
   ArrayResize(closes, 2);
   closes[0] = 10.0;
   closes[1] = 0.0;

   CReturnSeriesReader reader;
   double returns[];
   bool ok = reader.ComputeReturns(closes, ArraySize(closes), returns);
   ASSERT(!ok, "ComputeReturns must fail rather than divide by a zero-priced close.");
  }

The second cluster covers ComputePairCorrelation(): a series related to another by an exact positive linear formula checks that the coefficient comes out at exactly 1.0, an exact negative formula checks -1.0, a constant series checks that the zero-variance case is reported as undefined, and a single-point series checks the same for having too little data.

//+-----------------------------------------------------------------------+
//| TestComputePairCorrelationPerfectPositive                             |
//| Verifies that two series related by an exact positive linear formula  |
//| produce a correlation of exactly 1.0.                                 |
//+-----------------------------------------------------------------------+
void TestComputePairCorrelationPerfectPositive(void)
  {
   double series_a[] = {1.0, 2.0, 3.0, 4.0, 5.0};
   double series_b[] = {3.0, 5.0, 7.0, 9.0, 11.0}; // series_b = 2 * series_a + 1

   CPearsonCorrelationCalculator calculator;
   double correlation = 0.0;
   bool ok = calculator.ComputePairCorrelation(series_a, series_b, ArraySize(series_a), correlation);

   ASSERT(ok, "ComputePairCorrelation must succeed when both series have nonzero variance.");
   ASSERT(MathAbs(correlation - 1.0) < 0.0001, "An exact positive linear relationship must give a correlation of 1.0.");
  }

//+-----------------------------------------------------------------------+
//| TestComputePairCorrelationPerfectNegative                             |
//| Verifies that two series related by an exact negative linear formula  |
//| produce a correlation of exactly -1.0.                                |
//+-----------------------------------------------------------------------+
void TestComputePairCorrelationPerfectNegative(void)
  {
   double series_a[] = {1.0, 2.0, 3.0, 4.0, 5.0};
   double series_b[] = {-1.0, -2.0, -3.0, -4.0, -5.0}; // series_b = -series_a

   CPearsonCorrelationCalculator calculator;
   double correlation = 0.0;
   bool ok = calculator.ComputePairCorrelation(series_a, series_b, ArraySize(series_a), correlation);

   ASSERT(ok, "ComputePairCorrelation must succeed when both series have nonzero variance.");
   ASSERT(MathAbs(correlation - (-1.0)) < 0.0001, "An exact negative linear relationship must give a correlation of -1.0.");
  }

//+-----------------------------------------------------------------------+
//| TestComputePairCorrelationZeroVariance                                |
//| Verifies that a constant series, one with zero variance, is reported  |
//| as undefined rather than as a computed correlation of zero.           |
//+-----------------------------------------------------------------------+
void TestComputePairCorrelationZeroVariance(void)
  {
   double series_a[] = {1.0, 2.0, 3.0};
   double series_b[] = {5.0, 5.0, 5.0}; // constant, zero variance

   CPearsonCorrelationCalculator calculator;
   double correlation = 0.0;
   bool ok = calculator.ComputePairCorrelation(series_a, series_b, ArraySize(series_a), correlation);
   ASSERT(!ok, "ComputePairCorrelation must report undefined when either series has zero variance.");
  }

//+-----------------------------------------------------------------------+
//| TestComputePairCorrelationInsufficientData                            |
//| Verifies that a single data point, which has no variance to speak of, |
//| is reported as a failure.                                             |
//+-----------------------------------------------------------------------+
void TestComputePairCorrelationInsufficientData(void)
  {
   double series_a[] = {1.0};
   double series_b[] = {2.0};

   CPearsonCorrelationCalculator calculator;
   double correlation = 0.0;
   bool ok = calculator.ComputePairCorrelation(series_a, series_b, ArraySize(series_a), correlation);
   ASSERT(!ok, "ComputePairCorrelation must fail when fewer than two data points are supplied.");
  }

The third cluster checks the matrix itself: the flat-array index mapping at the corners of a 3x3 matrix, symmetry and a defined diagonal for two correlated series, and the undefined case for a zero-variance symbol both against itself and against a normal symbol.

//+----------------------------------------------------------------------------+
//| TestMatrixIndexBoundaries                                                  |
//| Verifies the flat-array index mapping at the corners of a small            |
//| corr_matrix, which is exactly where an off-by-one error would first appear.|
//+----------------------------------------------------------------------------+
void TestMatrixIndexBoundaries(void)
  {
   string symbols[] = {"AAA", "BBB", "CCC"};
   CCorrelationMatrix corr_matrix;
   corr_matrix.Initialize(symbols, 3);

   ASSERT(corr_matrix.Index(0, 0) == 0, "The first row and first column must map to flat index 0.");
   ASSERT(corr_matrix.Index(0, 2) == 2, "The first row, last column must map to flat index 2.");
   ASSERT(corr_matrix.Index(1, 0) == 3, "The second row, first column must map to flat index 3.");
   ASSERT(corr_matrix.Index(2, 2) == 8, "The last row, last column must map to flat index 8, the final slot in a 3x3 corr_matrix.");
  }

//+------------------------------------------------------------------------+
//| TestBuildMatrixSymmetryAndDiagonal                                     |
//| Verifies that BuildMatrix() produces a symmetric corr_matrix, that the |
//| diagonal comes out defined and equal to 1.0 for a symbol with genuine  |
//| variance, and that the mirrored cell matches the originally computed   |
//| cell exactly.                                                          |
//+------------------------------------------------------------------------+
void TestBuildMatrixSymmetryAndDiagonal(void)
  {
   CSymbolReturnSeries series[];
   ArrayResize(series, 2);
   series[0].m_symbol = "SYM1";
   double returns0[]  = {1.0, 2.0, 3.0, 4.0, 5.0};
   ArrayCopy(series[0].m_returns, returns0);
   series[0].m_count = ArraySize(returns0);

   series[1].m_symbol = "SYM2";
   double returns1[]  = {3.0, 5.0, 7.0, 9.0, 11.0}; // perfectly correlated with series[0]
   ArrayCopy(series[1].m_returns, returns1);
   series[1].m_count = ArraySize(returns1);

   CPearsonCorrelationCalculator calculator;
   CCorrelationMatrix corr_matrix;
   calculator.BuildMatrix(series, ArraySize(series), corr_matrix);

   ASSERT(corr_matrix.IsDefined(0, 1), "A pair of series with genuine variance must produce a defined correlation.");
   ASSERT(MathAbs(corr_matrix.GetValue(0, 1) - corr_matrix.GetValue(1, 0)) < 0.0001, "The corr_matrix must be symmetric across the diagonal.");
   ASSERT(corr_matrix.IsDefined(0, 0) && MathAbs(corr_matrix.GetValue(0, 0) - 1.0) < 0.0001, "A symbol with variance must correlate with itself at exactly 1.0.");
   ASSERT(corr_matrix.IsDefined(1, 1) && MathAbs(corr_matrix.GetValue(1, 1) - 1.0) < 0.0001, "The second symbol must also self-correlate at exactly 1.0.");
  }

//+-------------------------------------------------------------------------+
//| TestBuildMatrixUndefinedPair                                            |
//| Verifies that a symbol with zero-variance returns produces an undefined |
//| correlation both against itself and against a normal symbol, while the  |
//| normal symbol's own self-correlation remains defined.                   |
//+-------------------------------------------------------------------------+
void TestBuildMatrixUndefinedPair(void)
  {
   CSymbolReturnSeries series[];
   ArrayResize(series, 2);
   series[0].m_symbol = "FLAT";
   double flat_returns[] = {5.0, 5.0, 5.0};
   ArrayCopy(series[0].m_returns, flat_returns);
   series[0].m_count = ArraySize(flat_returns);

   series[1].m_symbol = "NORMAL";
   double normal_returns[] = {1.0, 2.0, 3.0};
   ArrayCopy(series[1].m_returns, normal_returns);
   series[1].m_count = ArraySize(normal_returns);

   CPearsonCorrelationCalculator calculator;
   CCorrelationMatrix corr_matrix;
   calculator.BuildMatrix(series, ArraySize(series), corr_matrix);

   ASSERT(!corr_matrix.IsDefined(0, 0), "A zero-variance symbol must be undefined even against itself.");
   ASSERT(!corr_matrix.IsDefined(0, 1), "A zero-variance symbol paired with a normal symbol must be undefined.");
   ASSERT(corr_matrix.IsDefined(1, 1), "The normal symbol must still self-correlate at a defined value.");
  }

OnStart() runs every test in sequence and prints the final pass and fail count. Nothing here touches a chart, an open position, or bar history; every input across every test is synthetic.

//+------------------------------------------------------------------------+
//| Script program start function                                          |
//+------------------------------------------------------------------------+
void OnStart(void)
  {
   g_assertion_passes   = 0;
   g_assertion_failures = 0;

   ::Print("Running TestCorrelationAnalytics...");

   TestComputeReturnsBasic();
   TestComputeReturnsInsufficientData();
   TestComputeReturnsZeroDivision();
   TestComputePairCorrelationPerfectPositive();
   TestComputePairCorrelationPerfectNegative();
   TestComputePairCorrelationZeroVariance();
   TestComputePairCorrelationInsufficientData();
   TestMatrixIndexBoundaries();
   TestBuildMatrixSymmetryAndDiagonal();
   TestBuildMatrixUndefinedPair();

   ::PrintFormat("TestCorrelationAnalytics complete: %d passed, %d failed.", g_assertion_passes, g_assertion_failures);
   if(g_assertion_failures == 0)
      ::Print("TEST SUITE PASSED");
   else
      ::Print("TEST SUITE FAILED");
  }


Extending the Dashboard

Weighting the window by recency: ComputePairCorrelation() currently treats every return in the window equally. An exponentially weighted variant that gives more influence to recent returns would make the matrix react faster to a genuine regime change, at the cost of more noise from any single outlier bar.

Filtering by magic number or account: CPositionSymbolCollector::Collect currently reads every open position on the account without distinction. Adding a magic-number filter would let a trader running several independent strategies on one account see the correlation exposure of just one strategy's own book.

Alerting instead of only printing: CCorrelationSummaryPrinter::Print already isolates every warning-threshold pair in one place. Routing that same information into SendNotification() or a sound alert would turn this project from something a trader has to be looking at into something that reaches them.

Persisting the matrix history: Neither CCorrelationMatrix nor the Expert Advisor currently keeps any record of past matrices. Writing each new matrix to a file alongside its timestamp, after BuildMatrix() runs, would let a trader later chart how a specific pair's correlation actually evolved over days or weeks, rather than only ever seeing its current value.

Ranking symbols before truncating: When the open-position count exceeds InpMaxSymbols, CPositionSymbolCollector currently keeps whichever symbols the terminal happens to enumerate first. Sorting candidates by open volume or absolute floating profit before truncating would make the kept symbols the ones that matter most to the account, rather than an arbitrary subset.


Limitations and Design Tradeoffs

The correlation window is anchored to bar closes on a single timeframe, InpTimeframe, measured against the chart the Expert Advisor is attached to, not against each monitored symbol individually. A pair that only starts moving together intraday, well before the next bar closes on the host chart, will not show up in the matrix until that bar finally closes, so this monitor trades some responsiveness for stability rather than trying to react to every tick within a bar. A monitored symbol that receives a new bar while the host chart has not yet received one of its own will still wait for the host chart's next bar or next tick before the matrix refreshes. Opening or closing a position is checked on every tick of the host chart, so it is normally picked up quickly, though the exact delay depends on how often the host chart itself ticks. 

Because every genuine change triggers a full report, an account with frequent position turnover or a short InpTimeframe produces a correspondingly busy Experts tab. This follows directly from prioritizing prompt updates over a quiet log, and is expected behavior rather than an unintended side effect.

Every return series assumes its own symbol's bar-shift index lines up meaningfully with every other symbol's bar-shift index at the same position. That assumption holds well for symbols that share very similar trading sessions, most forex majors against each other, but it becomes weaker for symbols with materially different session calendars, a forex pair against an equity index or a commodity with its own separate trading hours, where "50 bars ago" on one symbol and "50 bars ago" on another do not necessarily correspond to the same real-world stretch of time.

This project reports correlation between symbols' returns, not correlation between the account's actual profit and loss streams. A trader with a long EURUSD position and a short GBPUSD position, for example, benefits from the positive correlation between those two symbols rather than being exposed to it, since a move that hurts one position helps the other. The heatmap has no way to see position direction or size, so a reader has to supply that context before deciding whether a warning border represents real concentration.

When the open-position symbol count exceeds InpMaxSymbols, the monitor keeps the first symbols returned by the position scan and drops the rest. The terminal does not guarantee that this order reflects exposure, risk, or any other analytically meaningful ranking; it simply reflects internal position storage order. A trader who regularly exceeds the limit may want to raise InpMaxSymbols rather than rely on the truncation, or extend the collector to rank positions by exposure before truncating.

The exact-zero-variance check that marks a pair as undefined relies on floating-point equality, the same category of edge case this project's other exact comparisons rely on. A symbol whose price barely moved across the window, rather than not moving at all, will not trigger this branch and will instead produce a defined but extremely noisy correlation figure, since a handful of tiny returns can produce a coefficient that swings wildly between one recalculation and the next. A minimum-variance threshold, rather than a strict zero check, would be a reasonable refinement for a genuinely quiet symbol.

Finally, and worth stating plainly: a warning border means two symbols have recently moved together or against each other with a strong, measurable pattern. It does not mean they are guaranteed to keep doing so, and it does not mean trading them together is a mistake. A strongly negative correlation between two positions held in opposite directions can reduce risk rather than add to it. Some strategies deliberately take correlated positions on purpose. This project's job is to make the pattern visible, not to judge it or to make the position-sizing decision for the trader.


Conclusion

What this comes down to is a simple loop, run over and over for as long as the Expert Advisor stays on the chart: look at what's actually open right now, pull recent price history for each of those symbols, work out how closely they've been moving together, and put that in front of the trader in a form they can read at a glance. Nothing about that loop is complicated on its own. CPositionSymbolCollector just deduplicates a list. CReturnSeriesReader just turns prices into percentage changes. CPearsonCorrelationCalculator runs a formula most traders learned once and forgot. The value is in doing all of it continuously, and in handling the edge cases, a flat symbol, a closed position, and a shrinking array—honestly rather than papering over them.

That last part turned out to matter more in practice than it might look on paper. A stale array left behind by a copy that only grows and never shrinks was enough to turn a working fix into a runaway log. Getting the analytics right and getting the plumbing right around them are genuinely two different jobs, and this project needed both.

What the heatmap actually gives a trader is a way to see co-movement that a position list hides. Four open positions can look like four independent bets or four versions of the same bet, and comparing how they have actually been moving is one useful way to tell the difference, alongside position size and direction, which this project does not evaluate. That's what this panel is for. It won't tell a trader whether a correlated position is a mistake. Plenty of the time it is not. But it puts the fact in front of them instead of leaving it buried in four separate, unremarkable-looking trades.


Programs used in the article:

# Name Type Description
1 CorrelationTypes.mqh Include File Defines CSymbolReturnSeries and CCorrelationMatrix, the return-series and flat-matrix data structures
2 PositionSymbolCollector.mqh Include File Defines CPositionSymbolCollector, which deduplicates the symbols of every open position
3 ReturnSeriesReader.mqh Include File Defines CReturnSeriesReader, which builds a bar-to-bar return series per symbol
4 PearsonCorrelationCalculator.mqh Include File Defines CPearsonCorrelationCalculator, which computes pairwise correlation and assembles the full matrix
5 CorrelationHeatmapChart.mqh Include File Defines CCorrelationHeatmapChart, which renders the live correlation heatmap using CCanvas
6 CorrelationSummaryPrinter.mqh Include File Defines CCorrelationSummaryPrinter, which prints warnings and the highest and lowest correlation to the Experts tab
7 SymbolCorrelationMonitor.mq5 Expert Advisor The main Expert Advisor that wires all components together and keeps the heatmap current
8 TestCorrelationAnalytics.mq5 Script A verification script that checks return computation, correlation arithmetic, and matrix indexing using synthetic data
9 SymbolCorrelationMonitor.zip Zip Archive Zip archive containing all the attached files and their paths relative to the terminal's root folder.


The Maximal Information Coefficient: Detecting Any Relationship, and the Null That Decides Whether It Is Real The Maximal Information Coefficient: Detecting Any Relationship, and the Null That Decides Whether It Is Real
This article implements the Maximal Information Coefficient (MINE) for MQL5, including the grid search with dynamic programming and the four MINE statistics. It explains why raw MIC has a nonzero noise floor and builds a permutation null to judge significance. The result is a verified library with a dependence scanner and a chart indicator, allowing you to test features and interpret scores consistently across relationship shapes.
Price Action Analysis Toolkit Development (Part 82): Annotating Historical Bookmarks with Reliable Timeframe Switching Price Action Analysis Toolkit Development (Part 82): Annotating Historical Bookmarks with Reliable Timeframe Switching
We implement visible bookmark annotations and a hand-off mechanism for cross-symbol and cross-timeframe recall. The navigator stores the target in terminal Global Variables, waits for stable bars after reinitialization, and rebuilds only matching markers with safe cleanup. Traders can inspect saved events in context and navigate back to them without reconstructing chart settings.
MiniRocket: A Deterministic Time-Series Classifier and What It Finds in Seven Classic Setups MiniRocket: A Deterministic Time-Series Classifier and What It Finds in Seven Classic Setups
This article delivers a native MQL5 MiniRocket: 84 fixed convolution kernels yield 9,996 features quickly and deterministically, requiring no training loop and no external runtime. We verify the port against sktime and a float64 reimplementation, then run a reproducible audit of seven classic setups; planted and coin‑flip controls confirm correctness, and a 25%‑flipped control sets the detection threshold.
Practical Modules from Other Languages in MQL5 (Part 08): MATPLOTLIB Practical Modules from Other Languages in MQL5 (Part 08): MATPLOTLIB
Learn how to bring advanced plotting and data visualization to the MetaTrader 5 terminal by leveraging Matplotlib, one of Python's most powerful and widely used visualization libraries.