preview
Exporting Custom Indicator Buffers to CSV for Python Backtesting Pipelines

Exporting Custom Indicator Buffers to CSV for Python Backtesting Pipelines

MetaTrader 5Indicators |
149 0
Ushana Kevin Iorkumbul
Ushana Kevin Iorkumbul

Introduction

A trader who has built a custom indicator in MQL5 and wants to backtest a strategy around it in Python usually faces two awkward choices. The first option is to reimplement the indicator in Python. This risks drift from the MQL5 version: an off-by-one bar shift, different rounding, or different warm-up handling. Any of these can silently produce a backtest that does not match the chart. The second option is to trust that the reimplementation is exact and hope no discrepancy exists.

There is a third option, and it removes the risk entirely: let MQL5 compute the indicator, since that is the one place its logic is guaranteed correct, and export the resulting buffer values directly into a CSV file that Python can read. Whatever the indicator would have plotted on the chart is exactly what ends up in the file. No reimplementation, no drift, no guessing.

This article builds an MQL5 script that attaches to a custom indicator via iCustom(). It waits for calculation to complete (BarsCalculated()), copies selected buffers (CopyBuffer()), aligns them with price bars (CopyRates()), and writes a locale-safe CSV to MQL5/Files/. A Python script using pandas can load that file in a single call and merge it directly into a backtesting pipeline. The implementation separates row storage, the export pipeline, and the demo indicator used for testing into distinct modules with clearly bounded responsibilities.

Indicator Exporter Pipeline

A five-step pipeline showing how MQL5 computes indicator buffers, fetches them via iCustom() and CopyBuffer(), writes a locale-safe CSV through CIndicatorExporter, and loads the result into a pandas DataFrame where warm-up rows appear as NaN.


Why Export Rather Than Reimplement

Reimplementing an indicator in Python means reproducing every detail of its calculation: the exact smoothing method, the exact warm-up period length, the exact handling of the first few bars where a moving average or standard deviation has insufficient history to compute a value. Any of these details, if implemented slightly differently, produces values that look plausible but are numerically wrong. The backtest then reports performance for a strategy that never actually existed, because the entry and exit signals were computed against indicator values the live MQL5 indicator would never have produced.

Exporting the buffer values sidesteps this problem entirely. The MQL5 terminal is running the actual indicator code, the same code that would plot on a live chart. Whatever numbers that code produces are the numbers written to the CSV file. A Python backtest that reads those numbers is testing against the real indicator, not an approximation of it. This matters most for indicators with nontrivial internal state, custom smoothing constants, or conditional logic that would be tedious and error-prone to port line by line into another language.


The iCustom and CopyBuffer API

iCustom() creates a handle to a custom indicator, identical in spirit to how iMA() creates a handle to a built-in moving average. Its signature accepts the symbol, the timeframe, the indicator name, and then a variable list of the indicator's own input parameters in the same order the indicator's input declarations expect them.

The indicator name passed to iCustom() is a path relative to MQL5/Indicators/. If the compiled indicator sits directly in that folder, the bare filename without an extension is sufficient. If the indicator lives in a subfolder, the subfolder must be included in the name using a double backslash, for example "IndicatorExporter\\SimpleBandsDemo", because a single backslash in an MQL5 string literal is an escape character and does not produce a literal path separator on its own.

The variable parameter list is where a generic exporter runs into a language constraint. iCustom() itself is a built-in function that MQL5 allows to be called with different numbers of arguments, but user code calling it must still supply a fixed, compile-time number of arguments at any single call site. There is no way to write one call to iCustom() that forwards a runtime-determined number of parameters. The practical solution, used throughout this implementation, is a small dispatch block that calls iCustom() once for each possible parameter count and selects the matching call at runtime with a switch statement.

Once a handle exists, CopyBuffer() retrieves the values from one of the indicator's output buffers. Buffers are numbered starting at zero in the order the indicator's SetIndexBuffer() calls declared them. CopyBuffer() supports a date-range overload, CopyBuffer(handle, buffer_index, from, to, array[]), which returns every value between two timestamps without the caller needing to compute a bar count first. This implementation uses that overload throughout.


Waiting for Indicator Calculation to Complete

A custom indicator does not calculate instantly the moment its handle is created. On the first call to iCustom() for a given symbol, timeframe, and parameter set, the terminal schedules the indicator's OnCalculate() to run across the requested history, and that calculation happens asynchronously. If CopyBuffer() is called immediately after iCustom() returns, it frequently returns fewer bars than expected, or no bars at all, because the indicator has not yet processed the full history.

BarsCalculated() reports how many bars the indicator has processed so far. First, compute the required bar count for the date range with Bars(). Then poll BarsCalculated() with short Sleep() intervals until the indicator reports at least that many bars or a timeout is reached.

One detail matters here that is easy to miss. Immediately after iCustom() returns a valid handle, a call to BarsCalculated() can occasionally return a negative value for a brief moment, before the terminal has fully registered the indicator instance internally. This is a transient condition, not a genuine failure, and the polling loop must treat a negative result the same way it treats "not enough bars yet": by waiting and trying again, rather than aborting on the first negative reading. Treating every negative result as fatal produces intermittent, hard-to-reproduce failures on the very first Fetch() call of a session, which disappear entirely if the same call is simply retried a moment later.

Skipping the wait for calculation entirely, or aborting too eagerly on a transient negative reading, is the most common cause of a first export producing an unexpectedly short, empty, or spuriously failed result.


CSV Formatting and the Locale Pitfall

MQL5 provides a FILE_CSV file mode that lets FileWrite() automatically insert delimiters between arguments. This is convenient, but it carries a real risk: the delimiter and the numeric formatting FileWrite() uses can be affected by the terminal's regional settings on some systems, and a European locale that uses a comma as the decimal separator can corrupt a comma-delimited file by placing a decimal comma inside what should be a field separator.

This implementation avoids FILE_CSV mode entirely. Every field is formatted explicitly into a string using DoubleToString(), which always renders a decimal point regardless of the operating system's regional configuration, and every row is assembled as one complete string with the fields joined by a literal comma. The finished line is written with FileWriteString(), which writes exactly the bytes given with no automatic reformatting. This produces a file that is correct on every machine, in every locale, with no surprises.

A second detail matters during the warm-up period: a 20-bar moving average has no valid value for its first 19 bars. Custom indicators are expected to mark those bars using the built-in EMPTY_VALUE sentinel, a very large placeholder number, rather than leaving the buffer slot untouched. This matters: if a buffer is not explicitly written during warm-up, it does not contain a predictable default such as zero. It retains whatever was already present in that memory location, which on a long recalculation across many years of history can be leftover data from an earlier point in the same calculation pass. An indicator author who assumes the terminal pre-fills unwritten buffer slots with a safe default is trusting an assumption that does not hold; the indicator must explicitly assign EMPTY_VALUE to every warm-up bar itself.

Writing the EMPTY_VALUE sentinel directly into a CSV file as a plain number would place a nonsensical value like 1.79769313486232e+308 into what should be an empty cell, and any numeric column in pandas containing that value would have its statistics destroyed. The exporter checks every buffer value against EMPTY_VALUE before formatting it, and writes an empty field instead, which pandas correctly interprets as NaN when the file is loaded. This check is only meaningful if the indicator being exported actually writes the sentinel during its own warm-up period, which is why the demo indicator built later in this article does so explicitly.


Implementation — IndicatorRow.mqh

CIndicatorRow is a plain data struct holding one exported row: the bar's timestamp, its OHLC prices and tick volume, and up to eight indicator buffer values. It carries no methods; formatting and CSV-specific logic belong to the exporter, keeping this struct a pure data container.

Class Declaration

//+------------------------------------------------------------------+
//|                                                 IndicatorRow.mqh |
//+------------------------------------------------------------------+
#ifndef INDICATORROW_MQH
#define INDICATORROW_MQH
//+-------------------------------------------------------------------+
//| One exported row: a bar's OHLCV data plus its indicator values.   |
//| Supports up to 8 buffer values, sufficient for the great majority |
//| of custom indicators encountered in practice.                     |
//+-------------------------------------------------------------------+
struct CIndicatorRow
  {
   datetime bar_time;     // server time of the bar's open
   double   open;         // bar open price
   double   high;         // bar high price
   double   low;          // bar low price
   double   close;        // bar close price
   long     tick_volume;  // tick count for the bar
   double   values[8];    // up to 8 indicator buffer values for this bar
  };

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

The struct holds exactly the columns that appear in the final CSV file, in the same order they will be written. values[8] is a fixed-size array rather than a dynamic one because MQL5 struct arrays require fixed-size members when the struct itself is stored inside a dynamic array, which CIndicatorExporter does when it accumulates rows.


Implementation — IndicatorExporter.mqh

CIndicatorExporter owns the complete pipeline: creating the indicator handle with the correct dispatch for the parameter count, waiting for calculation to complete, fetching price data and buffer data over the requested range, and writing the finished CSV file.

One structural detail is worth explaining before the code. Retrieving several indicator buffers means the exporter needs an array of arrays: one dynamic double[] per requested buffer. MQL5 does not support this directly. A multidimensional array in MQL5 may have a dynamic size only in its first dimension; every dimension after the first must be a fixed compile-time constant, so a declaration like double buffer_data[8][] is invalid, and even if it compiled, indexing into it would not produce a genuine resizable array that CopyBuffer() could write into. The standard technique for a jagged array in MQL5 is to wrap a single dynamic array inside a small struct, then declare a fixed-size array of that struct. Each struct instance owns its own independent, genuinely resizable array. This implementation uses exactly that pattern.

Class Declaration

//+------------------------------------------------------------------+
//|                                             IndicatorExporter.mqh|
//+------------------------------------------------------------------+
#ifndef INDICATOREXPORTER_MQH
#define INDICATOREXPORTER_MQH

#include "IndicatorRow.mqh"
//+------------------------------------------------------------------+
//| Wraps one dynamic double array so a fixed-size array of these    |
//| structs can act as a jagged array of buffer results. MQL5 does   |
//| not allow a dynamic dimension anywhere but the first position of |
//| a multidimensional array, so double[8][] is not valid; wrapping  |
//| each slot's array inside a struct member gives each slot its own |
//| genuinely resizable array that CopyBuffer() can write into.      |
//+------------------------------------------------------------------+
struct CBufferSlot
  {
   double            data[];
  };
//+------------------------------------------------------------------+
//| Fetches indicator buffer values aligned to price bars and writes |
//| the result to a locale-safe CSV file.                            |
//+------------------------------------------------------------------+
class CIndicatorExporter
  {
private:
   string            m_symbol;            // instrument to export
   ENUM_TIMEFRAMES   m_timeframe;         // chart timeframe for the indicator
   string            m_indicator_name;    // indicator name as it appears in MQL5/Indicators
   double            m_params[4];         // up to 4 numeric input parameters
   int               m_param_count;       // how many of m_params are actually used
   int               m_buffer_indices[8]; // which buffer indices to export
   int               m_buffer_count;      // number of buffers being exported
   datetime          m_from;              // start of the export range
   datetime          m_to;                // end of the export range
   int               m_handle;            // indicator handle from iCustom()
   CIndicatorRow     m_rows[];            // accumulated export rows
   int               m_row_count;         // number of valid rows in m_rows[]

   bool              CreateHandle(void);
   bool              WaitForCalculation(int required_bars, int timeout_ms);
   string            FormatDouble(double value, int digits) const;

public:
                     CIndicatorExporter(void);
                    ~CIndicatorExporter(void);

   void              Init(const string &symbol, ENUM_TIMEFRAMES timeframe,
                        const string &indicator_name,
                        const double &params[], int param_count,
                        const int &buffer_indices[], int buffer_count,
                        datetime from, datetime to);
   bool              Fetch(void);
   bool              WriteCsv(const string &filename, const string &buffer_names[]);
   int               GetRowCount(void) const;
  };

m_params is fixed at four slots because the dispatch switch in CreateHandle() must enumerate a bounded set of call signatures at compile time; four numeric inputs covers the great majority of custom indicators, and the pattern extends trivially to more slots if needed. m_buffer_indices and m_buffer_count let the caller request any subset of the indicator's buffers, in any order, rather than being forced to export every buffer the indicator declares.

Constructor

//+------------------------------------------------------------------+
//| Constructor — sets all fields to safe initial values.            |
//+------------------------------------------------------------------+
CIndicatorExporter::CIndicatorExporter(void)
  {
   m_symbol         = "";
   m_timeframe      = PERIOD_CURRENT;
   m_indicator_name = "";
   m_param_count    = 0;
   m_buffer_count   = 0;
   m_from           = 0;
   m_to             = 0;
   m_handle         = INVALID_HANDLE;
   m_row_count      = 0;
   ::ArrayResize(m_rows, 0); // start with an empty row array
  }

All fields start at neutral values. m_handle is initialized to INVALID_HANDLE so any early failure path can be detected reliably before Fetch() has run.

Destructor

//+------------------------------------------------------------------+
//| Destructor — releases the indicator handle if still open.        |
//+------------------------------------------------------------------+
CIndicatorExporter::~CIndicatorExporter(void)
  {
   if(m_handle != INVALID_HANDLE)
      ::IndicatorRelease(m_handle); // release the indicator instance
  }

IndicatorRelease() frees the indicator instance the terminal created for this handle. Failing to call it leaves the indicator running in the background even after the script that created it has finished, wasting terminal resources.

Init()

//+------------------------------------------------------------------+
//| Stores every export parameter; performs no fetching or writing.  |
//+------------------------------------------------------------------+
void CIndicatorExporter::Init(const string &symbol, ENUM_TIMEFRAMES timeframe,
                              const string &indicator_name,
                              const double &params[], int param_count,
                              const int &buffer_indices[], int buffer_count,
                              datetime from, datetime to)
  {
   m_symbol         = symbol;
   m_timeframe      = timeframe;
   m_indicator_name = indicator_name;
   m_from           = from;
   m_to             = to;

//--- copy the numeric parameters, clamped to the 4-slot limit
   m_param_count = ::MathMin(param_count, 4);
   for(int i = 0; i < m_param_count; i++)
      m_params[i] = params[i];

//--- copy the requested buffer indices, clamped to the 8-slot limit
   m_buffer_count = ::MathMin(buffer_count, 8);
   for(int i = 0; i < m_buffer_count; i++)
      m_buffer_indices[i] = buffer_indices[i];
  }

Init() clamps both the parameter count and the buffer count to the fixed array sizes declared in the class, silently discarding anything beyond the supported limit rather than writing out of bounds. This keeps the class safe even if a caller passes an oversized array by mistake.

CreateHandle()

//+-------------------------------------------------------------------+
//| Creates the indicator handle via iCustom(), dispatching to the    |
//| matching fixed-argument call based on m_param_count. MQL5 allows  |
//| iCustom() itself to accept a variable argument count, but any one |
//| call site must supply a fixed number of arguments known at        |
//| compile time, so a runtime-variable parameter count requires      |
//| this explicit switch across the supported counts.                 |
//+-------------------------------------------------------------------+
bool CIndicatorExporter::CreateHandle(void)
  {
   switch(m_param_count)
     {
      case 0:
         m_handle = ::iCustom(m_symbol, m_timeframe, m_indicator_name);
         break;
      case 1:
         m_handle = ::iCustom(m_symbol, m_timeframe, m_indicator_name,
                              m_params[0]);
         break;
      case 2:
         m_handle = ::iCustom(m_symbol, m_timeframe, m_indicator_name,
                              m_params[0], m_params[1]);
         break;
      case 3:
         m_handle = ::iCustom(m_symbol, m_timeframe, m_indicator_name,
                              m_params[0], m_params[1], m_params[2]);
         break;
      default:
         m_handle = ::iCustom(m_symbol, m_timeframe, m_indicator_name,
                              m_params[0], m_params[1],
                              m_params[2], m_params[3]);
         break;
     }

   if(m_handle == INVALID_HANDLE)
     {
      ::PrintFormat("CIndicatorExporter::CreateHandle: iCustom('%s') failed, error %d",
                    m_indicator_name, ::GetLastError());
      return(false);
     }
   return(true);
  }

Each case calls iCustom() with exactly the number of parameters that case represents; the compiler resolves each call independently at compile time, and the switch selects the correct one at runtime based on how many parameters the caller actually supplied through Init(). The default case handles the maximum of four parameters. If this call fails, the most common causes are a missing or uncompiled indicator, or an indicator name that omits a required subfolder prefix.

WaitForCalculation()

//+------------------------------------------------------------------+
//| Polls BarsCalculated() until the indicator has processed at      |
//| least required_bars, or until timeout_ms elapses. Calling        |
//| CopyBuffer() before this completes is the most common cause of   |
//| a short or empty export on an indicator's first use.             |
//+------------------------------------------------------------------+
bool CIndicatorExporter::WaitForCalculation(int required_bars, int timeout_ms)
  {
   int elapsed  = 0;
   int interval = 50; // poll every 50 ms

   while(elapsed < timeout_ms)
     {
      int calculated = ::BarsCalculated(m_handle);
      //--- a negative result can be transient immediately after iCustom(),
      //--- before the terminal has finished registering the indicator
      //--- instance; treat it the same as "not enough bars yet" and retry
      //--- rather than failing on the first check
      if(calculated >= required_bars)
         return(true); // indicator has processed enough history

      ::Sleep(interval);
      elapsed += interval;
     }

   int final_calculated = ::BarsCalculated(m_handle);
   ::PrintFormat("CIndicatorExporter::WaitForCalculation: timed out after %d ms, "
                 "last BarsCalculated result %d of %d required",
                 timeout_ms, final_calculated, required_bars);
   return(false);
  }

The polling loop checks BarsCalculated() every 50 milliseconds. A negative return value is not treated as a distinct failure state; it simply fails the calculated >= required_bars comparison and the loop continues polling, exactly as it would for any other insufficient count. This avoids a spurious failure on the very first poll of a freshly created handle, while still respecting the overall timeout if the indicator genuinely never finishes calculating.

FormatDouble()

//+------------------------------------------------------------------+
//| Formats a double for CSV output, always using a decimal point    |
//| regardless of the system's regional settings. Returns an empty   |
//| string for the EMPTY_VALUE sentinel so pandas reads it as NaN    |
//| rather than as a corrupted, enormous placeholder number.         |
//+------------------------------------------------------------------+
string CIndicatorExporter::FormatDouble(double value, int digits) const
  {
   if(value == EMPTY_VALUE)
      return(""); // warm-up period; no value available for this bar
   return(::DoubleToString(value, digits));
  }

The EMPTY_VALUE check catches the sentinel that a well-behaved custom indicator writes during its warm-up period, before which it lacks sufficient history to compute a value. DoubleToString() is used for every other value because it always produces a decimal point, unlike some locale-sensitive formatting paths, which is the core of the CSV safety design from Section 4.

Fetch()

//+------------------------------------------------------------------+
//| Creates the handle, waits for calculation, and retrieves price   |
//| data and every requested buffer over the configured date range.  |
//| If buffers return fewer bars than the price data (a rare warm-up |
//| or feed edge case), rows are truncated to the shortest series    |
//| returned so all columns remain aligned to the same bar times.    |
//+------------------------------------------------------------------+
bool CIndicatorExporter::Fetch(void)
  {
   if(!CreateHandle())
      return(false);

//--- determine how many bars the requested range covers and wait for
//--- the indicator to finish calculating across that many bars
   int required = ::Bars(m_symbol, m_timeframe, m_from, m_to);
   if(!WaitForCalculation(required, 10000))
      return(false);

//--- fetch price data for the range in a single call
   MqlRates rates[];
   int price_count = ::CopyRates(m_symbol, m_timeframe, m_from, m_to, rates);
   if(price_count <= 0)
     {
      ::PrintFormat("CIndicatorExporter::Fetch: CopyRates returned no bars, error %d",
                    ::GetLastError());
      return(false);
     }

   int min_count = price_count;

//--- fetch each requested buffer; buffers share the same time axis
//--- as the underlying price series for the same symbol and timeframe
   CBufferSlot buffer_data[8];
   for(int b = 0; b < m_buffer_count; b++)
     {
      int n = ::CopyBuffer(m_handle, m_buffer_indices[b], m_from, m_to, buffer_data[b].data);
      if(n <= 0)
        {
         ::PrintFormat("CIndicatorExporter::Fetch: CopyBuffer(index=%d) returned no data, error %d",
                       m_buffer_indices[b], ::GetLastError());
         return(false);
        }
      if(n < min_count)
         min_count = n; // shrink to the shortest series so columns stay aligned
     }

//--- assemble the final row set, one row per aligned bar
   ::ArrayResize(m_rows, min_count);
   for(int i = 0; i < min_count; i++)
     {
      m_rows[i].bar_time    = rates[i].time;
      m_rows[i].open        = rates[i].open;
      m_rows[i].high        = rates[i].high;
      m_rows[i].low         = rates[i].low;
      m_rows[i].close       = rates[i].close;
      m_rows[i].tick_volume = rates[i].tick_volume;

      for(int b = 0; b < m_buffer_count; b++)
         m_rows[i].values[b] = buffer_data[b].data[i];
     }

   m_row_count = min_count;
   return(true);
  }

Fetch() first ensures the indicator has finished calculating, then retrieves the price series with a single CopyRates() call, then retrieves each requested buffer into its own CBufferSlot.data array. Because CopyRates() and CopyBuffer() are both anchored to the same symbol and timeframe over the same date range, their returned arrays share the same chronological ordering index-for-index. The defensive min_count tracking handles the rare case where a buffer returns fewer bars than the price series, truncating everything to the shortest series so every column in the final CSV always refers to the same bar.

WriteCsv()

//+------------------------------------------------------------------+
//| Writes the accumulated rows to a CSV file. Every field is        |
//| formatted explicitly and joined manually rather than relying on  |
//| FILE_CSV mode, avoiding locale-dependent delimiter or decimal    |
//| separator corruption on non-English regional settings.           |
//+------------------------------------------------------------------+
bool CIndicatorExporter::WriteCsv(const string &filename, const string &buffer_names[])
  {
   int handle = ::FileOpen(filename, FILE_WRITE | FILE_TXT | FILE_ANSI);
   if(handle == INVALID_HANDLE)
     {
      ::PrintFormat("CIndicatorExporter::WriteCsv: cannot open '%s', error %d",
                    filename, ::GetLastError());
      return(false);
     }

   int digits = (int)::SymbolInfoInteger(m_symbol, SYMBOL_DIGITS);

//--- build and write the header row
   string header = "time,open,high,low,close,tick_volume";
   for(int b = 0; b < m_buffer_count; b++)
      header += "," + buffer_names[b];
   ::FileWriteString(handle, header + "\r\n");

//--- build and write each data row
   for(int i = 0; i < m_row_count; i++)
     {
      string line = ::TimeToString(m_rows[i].bar_time,
                                   TIME_DATE | TIME_MINUTES | TIME_SECONDS);
      line += "," + FormatDouble(m_rows[i].open,  digits);
      line += "," + FormatDouble(m_rows[i].high,  digits);
      line += "," + FormatDouble(m_rows[i].low,   digits);
      line += "," + FormatDouble(m_rows[i].close, digits);
      line += "," + ::IntegerToString(m_rows[i].tick_volume);

      for(int b = 0; b < m_buffer_count; b++)
         line += "," + FormatDouble(m_rows[i].values[b], digits);

      ::FileWriteString(handle, line + "\r\n");
     }

   ::FileClose(handle);
   ::PrintFormat("CIndicatorExporter::WriteCsv: wrote %d rows to %s", m_row_count, filename);
   return(true);
  }

WriteCsv() opens the file with FILE_TXT | FILE_ANSI, deliberately avoiding FILE_CSV, and builds every line as a single string before writing it with one FileWriteString() call per row. The header row is built the same way, ensuring the column count always matches the number of buffer names the caller supplied.

GetRowCount()

//+------------------------------------------------------------------+
//| Returns the number of rows fetched and ready for export.         |
//+------------------------------------------------------------------+
int CIndicatorExporter::GetRowCount(void) const
  {
   return(m_row_count);
  }

GetRowCount() lets the calling script log a summary or run a sanity check before deciding whether the export was successful enough to report as complete.


Implementation — SimpleBandsDemo.mq5

SimpleBandsDemo is a small custom indicator used throughout this article to demonstrate and test the exporter end to end. It plots three buffers, an upper band, a middle line, and a lower band, computed as a simple moving average plus and minus a multiple of the standard deviation over the same period, similar to Bollinger Bands.

A well-behaved custom indicator must explicitly mark bars that fall inside its warm-up period, the range where it does not yet have enough history to compute a value. It is not sufficient to simply leave those buffer slots untouched. Buffer arrays registered with SetIndexBuffer() do not automatically start out filled with a safe placeholder; they retain whatever data was previously present in that memory, which on a long recalculation spanning years of history can be leftover values from an earlier point in the same calculation pass. An indicator that assumes unwritten slots default to zero or to EMPTY_VALUE is trusting an assumption that does not hold. This indicator writes EMPTY_VALUE into every warm-up bar explicitly, the first time it runs across the full history.

Property Block and Buffers

//+------------------------------------------------------------------+
//|                                              SimpleBandsDemo.mq5 |
//+------------------------------------------------------------------+

#property indicator_chart_window
#property indicator_buffers 3
#property indicator_plots   3
#property indicator_label1  "Upper"
#property indicator_label2  "Mid"
#property indicator_label3  "Lower"
#property indicator_type1   DRAW_LINE
#property indicator_type2   DRAW_LINE
#property indicator_type3   DRAW_LINE
#property indicator_color1  clrDodgerBlue
#property indicator_color2  clrOrange
#property indicator_color3  clrDodgerBlue
#property indicator_width1  2
#property indicator_width2  2
#property indicator_width3  2

//--- Inputs                                                           
input int    InpPeriod     = 20;  // Averaging period
input double InpDeviations = 2.0; // Standard deviation multiplier

//--- Indicator buffers                                               
double BufUpper[];
double BufMid[];
double BufLower[];

indicator_chart_window places the plotted lines directly on the price chart rather than a separate subwindow, appropriate for a band overlay. The color properties give the upper and lower bands a consistent blue and the midline a contrasting orange, making all three lines clearly visible against candlesticks. The width properties set each line to two pixels, which is enough weight to read clearly at normal chart zoom without obscuring price action. Three buffers are declared to match the three plots.

OnInit()

//+------------------------------------------------------------------+
//| Indicator initialization: binds buffers and sets display names.  |
//+------------------------------------------------------------------+
int OnInit()
  {
   ::SetIndexBuffer(0, BufUpper, INDICATOR_DATA);
   ::SetIndexBuffer(1, BufMid,   INDICATOR_DATA);
   ::SetIndexBuffer(2, BufLower, INDICATOR_DATA);

//--- declare EMPTY_VALUE as the empty marker for all three plots so the
//--- chart itself skips drawing warm-up bars, matching the sentinel the
//--- exporter checks for when writing blank CSV fields
   ::PlotIndexSetDouble(0, PLOT_EMPTY_VALUE, EMPTY_VALUE);
   ::PlotIndexSetDouble(1, PLOT_EMPTY_VALUE, EMPTY_VALUE);
   ::PlotIndexSetDouble(2, PLOT_EMPTY_VALUE, EMPTY_VALUE);

   ::IndicatorSetString(INDICATOR_SHORTNAME,
                        "SimpleBandsDemo(" + ::IntegerToString(InpPeriod) + ")");
   return(INIT_SUCCEEDED);
  }

SetIndexBuffer() binds each array to its buffer index in the order the exporter will reference them: index 0 is Upper, index 1 is Mid, index 2 is Lower. PlotIndexSetDouble() with PLOT_EMPTY_VALUE tells the terminal's charting engine which sentinel value marks a point that should not be drawn, so the chart itself correctly skips the warm-up period visually. This setting affects only how the chart draws the line; it does not retroactively fill buffer memory, which is why OnCalculate() must still write the sentinel explicitly.

OnCalculate()

//+------------------------------------------------------------------+
//| Computes the SMA and standard-deviation bands for every bar.     |
//+------------------------------------------------------------------+
int OnCalculate(const int rates_total, const int prev_calculated,
                const datetime &time[], const double &open[],
                const double &high[], const double &low[],
                const double &close[], const long &tick_volume[],
                const long &volume[], const int &spread[])
  {
   if(rates_total < InpPeriod)
      return(0); // not enough bars yet for even the first calculation

//--- explicitly mark every warm-up bar as EMPTY_VALUE on the first full
//--- calculation; without this, the buffer slots retain whatever memory
//--- was previously present (often garbage on a long recalculation) and
//--- an exporter relying on the EMPTY_VALUE sentinel would misreport it
   if(prev_calculated == 0)
     {
      int warmup_end = ::MathMin(InpPeriod - 1, rates_total);
      for(int i = 0; i < warmup_end; i++)
        {
         BufUpper[i] = EMPTY_VALUE;
         BufMid[i]   = EMPTY_VALUE;
         BufLower[i] = EMPTY_VALUE;
        }
     }

   int start = (prev_calculated > InpPeriod) ? prev_calculated - 1 : InpPeriod - 1;

   for(int i = start; i < rates_total; i++)
     {
      double sum = 0.0;
      for(int j = 0; j < InpPeriod; j++)
         sum += close[i - j];
      double mean = sum / InpPeriod;

      double variance = 0.0;
      for(int j = 0; j < InpPeriod; j++)
        {
         double diff = close[i - j] - mean;
         variance += diff * diff;
        }
      double stddev = ::MathSqrt(variance / InpPeriod);

      BufMid[i]   = mean;
      BufUpper[i] = mean + InpDeviations * stddev;
      BufLower[i] = mean - InpDeviations * stddev;
     }

   return(rates_total);
  }

The explicit warm-up write only runs once, guarded by prev_calculated == 0, which is true only on the indicator's first full pass across the entire requested history. On every subsequent incremental recalculation triggered by a new tick, prev_calculated is nonzero and this block is skipped, since the warm-up bars were already correctly written on the first pass and never need to be touched again. The main calculation loop begins at start, which resumes from where the previous calculation left off (prev_calculated - 1) on incremental updates, or from the first bar with a full period of history (InpPeriod - 1) on the very first calculation.

SimpleBandsDemo(20) plotted live on an ETHUSD Daily chart.

SimpleBandsDemo(20) plotted live on an ETHUSD Daily chart.


Implementation — IndicatorBufferExporter.mq5

This is the user-facing script. It accepts the symbol, timeframe, indicator name, up to four numeric parameters, a list of buffer indices and names, a date range, and an output filename, then drives the full export pipeline.

Property Block and Inputs

//+------------------------------------------------------------------+
//|                                       IndicatorBufferExporter.mq5|
//+------------------------------------------------------------------+

#property script_show_inputs

//--- Includes
#include <IndicatorExporter/IndicatorRow.mqh>
#include <IndicatorExporter/IndicatorExporter.mqh>

//--- Inputs
input string InpSymbol             = "";                    // Symbol (empty = current chart symbol)
input ENUM_TIMEFRAMES InpTimeframe = PERIOD_CURRENT;         // Timeframe
input string InpIndicatorName      = "IndicatorExporter\\SimpleBandsDemo";      // Indicator name in MQL5/Indicators
input double InpParam1             = 20;                     // Numeric parameter 1
input double InpParam2             = 2.0;                    // Numeric parameter 2
input double InpParam3             = 0;                      // Numeric parameter 3 (unused if 0 count)
input double InpParam4             = 0;                      // Numeric parameter 4 (unused if 0 count)
input int    InpParamCount         = 2;                      // How many of the above to pass
input string InpBufferIndices      = "0,1,2";                // Comma-separated buffer indices
input string InpBufferNames        = "Upper,Mid,Lower";      // Comma-separated CSV column names
input datetime InpFrom             = 0;                      // Start date
input datetime InpTo               = 0;                      // End date (0 = now)
input string InpOutputFilename     = "indicator_export.csv"; // Output filename in MQL5/Files/

InpBufferIndices and InpBufferNames are parsed as comma-separated lists, letting the same script export any subset of an indicator's buffers, in any order, without recompiling for each indicator. If the indicator being exported lives in a subfolder under MQL5/Indicators/, InpIndicatorName must include that subfolder using a double backslash, for example "MyTools\\SimpleBandsDemo".

ParseIntList() and ParseStringList()

//+------------------------------------------------------------------+
//| Splits a comma-separated string into an array of integers.       |
//+------------------------------------------------------------------+
int ParseIntList(const string &csv, int &out[])
  {
   string parts[];
   int count = ::StringSplit(csv, ',', parts);
   ::ArrayResize(out, count);
   for(int i = 0; i < count; i++)
      out[i] = (int)::StringToInteger(parts[i]);
   return(count);
  }
//+------------------------------------------------------------------+
//| Splits a comma-separated string into an array of strings.        |
//+------------------------------------------------------------------+
int ParseStringList(const string &csv, string &out[])
  {
   return(::StringSplit(csv, ',', out));
  }

Both helpers wrap StringSplit() with a comma separator, converting the InpBufferIndices and InpBufferNames inputs into usable arrays before they are passed to CIndicatorExporter.

OnStart()

//+------------------------------------------------------------------+
//| Script entry point: resolve inputs, run the export, and report.  |
//+------------------------------------------------------------------+
void OnStart()
  {
//--- resolve defaults
   string symbol = (InpSymbol == "") ? _Symbol : InpSymbol;
   ENUM_TIMEFRAMES tf = (InpTimeframe == PERIOD_CURRENT) ? _Period : InpTimeframe;
   datetime from = InpFrom;
   datetime to   = (InpTo == 0) ? ::TimeCurrent() : InpTo;

   if(from >= to)
     {
      ::PrintFormat("IndicatorBufferExporter: invalid range - from (%s) must be before to (%s).",
                    ::TimeToString(from), ::TimeToString(to));
      return;
     }

//--- parse the buffer index and name lists
   int    buffer_indices[];
   string buffer_names[];
   int idx_count  = ParseIntList(InpBufferIndices, buffer_indices);
   int name_count = ParseStringList(InpBufferNames, buffer_names);

   if(idx_count != name_count)
     {
      ::PrintFormat("IndicatorBufferExporter: buffer index count (%d) does not match "
                    "buffer name count (%d).", idx_count, name_count);
      return;
     }

//--- assemble the numeric parameter array from the four inputs
   double params[4];
   params[0] = InpParam1;
   params[1] = InpParam2;
   params[2] = InpParam3;
   params[3] = InpParam4;

   uint t_start = ::GetTickCount();

   CIndicatorExporter exporter;
   exporter.Init(symbol, tf, InpIndicatorName, params, InpParamCount,
                 buffer_indices, idx_count, from, to);

   if(!exporter.Fetch())
     {
      ::Print("IndicatorBufferExporter: fetch failed. Check the journal for details.");
      return;
     }

   if(!exporter.WriteCsv(InpOutputFilename, buffer_names))
     {
      ::Print("IndicatorBufferExporter: write failed. Check the journal for details.");
      return;
     }

   uint elapsed = ::GetTickCount() - t_start;
   ::PrintFormat("IndicatorBufferExporter: exported %d rows to MQL5/Files/%s in %d ms.",
                 exporter.GetRowCount(), InpOutputFilename, elapsed);
  }

OnStart() validates the date range and the matching lengths of the buffer index and name lists before doing any work, since a mismatch there would silently produce a CSV with the wrong column headers. The elapsed time and final row count are logged so the user has immediate confirmation of what the export produced.

indicator_export.csv file opened in Excel

The indicator_export.csv file opened in Excel, showing the first 21 rows. The Upper, Mid, and Lower columns are blank for the first 19 rows and populate from row 20 onward with 10.68 / 9.78 / 8.88, matching both the Python output and the chart values exactly.



Verification — TestIndicatorExporter.mq5

TestIndicatorExporter.mq5 runs the full pipeline against SimpleBandsDemo and inspects both the in-memory results and the written CSV file directly.

This test exports the full available history (from = 0), not just recent bars. This is intentional. An indicator's warm-up period exists only once, at the very beginning of its available history. If the test instead requested only the most recent bars, the assertion that checks for a blank warm-up row would only be meaningful if the indicator happened to have never calculated further back than that recent window in the current session, which cannot be relied upon. Requesting the full history guarantees the export window always includes the true beginning of the symbol's data, where the warm-up period genuinely exists.

//+------------------------------------------------------------------+
//|                                         TestIndicatorExporter.mq5|
//+------------------------------------------------------------------+

#property script_show_inputs

//--- Includes                                                         
#include <IndicatorExporter/IndicatorRow.mqh>
#include <IndicatorExporter/IndicatorExporter.mqh>

//--- ASSERT: prints PASSED or FAILED with the test description
#define ASSERT(cond, msg) \
   if(!(cond)) { PrintFormat("ASSERT FAILED : %s", msg); } \
   else        { PrintFormat("ASSERT PASSED : %s", msg); }
//+------------------------------------------------------------------+
//| Script entry point: run the exporter and verify every stage.     |
//+------------------------------------------------------------------+
void OnStart()
  {
   string symbol = _Symbol;
   ENUM_TIMEFRAMES tf = _Period;

//--- request from the very start of available history so the export
//--- window actually includes the indicator's warm-up period; using
//--- "the most recent N bars" instead would miss the warm-up entirely
//--- whenever the indicator has already calculated further back in
//--- the same session, which happens whenever a full-history export
//--- was run earlier and the indicator handle is still cached
   datetime from = 0;
   datetime to   = ::TimeCurrent();

   double params[4];
   params[0] = 20;  // InpPeriod for SimpleBandsDemo
   params[1] = 2.0; // InpDeviations for SimpleBandsDemo
   params[2] = 0;
   params[3] = 0;

   int buffer_indices[3];
   buffer_indices[0] = 0;
   buffer_indices[1] = 1;
   buffer_indices[2] = 2;

   string buffer_names[3];
   buffer_names[0] = "Upper";
   buffer_names[1] = "Mid";
   buffer_names[2] = "Lower";

   CIndicatorExporter exporter;
   exporter.Init(symbol, tf, "IndicatorExporter\\SimpleBandsDemo", params, 2,
                 buffer_indices, 3, from, to);

//--- Test 1: fetch succeeds
   bool fetched = exporter.Fetch();
   ASSERT(fetched, "Fetch() succeeds against SimpleBandsDemo");

   if(!fetched)
     {
      Print("TestIndicatorExporter: cannot continue without a successful fetch.");
      return;
     }

//--- Test 2: at least some rows were returned
   int row_count = exporter.GetRowCount();
   ASSERT(row_count > 0, "GetRowCount() returns a positive value after Fetch()");
   PrintFormat("TestIndicatorExporter: fetched %d rows", row_count);

//--- Test 3: WriteCsv succeeds
   string filename = "test_indicator_export.csv";
   bool written = exporter.WriteCsv(filename, buffer_names);
   ASSERT(written, "WriteCsv() writes the file without error");

//--- Test 4: the file exists on disk
   ASSERT(::FileIsExist(filename), "Exported CSV file exists in MQL5/Files/");

//--- read the file back to verify its structure directly
   int fh = ::FileOpen(filename, FILE_READ | FILE_TXT | FILE_ANSI);
   if(fh == INVALID_HANDLE)
     {
      PrintFormat("TestIndicatorExporter: could not reopen %s for verification, error %d",
                  filename, ::GetLastError());
      return;
     }

//--- Test 5: header line matches the expected column names
   string header_line = ::FileReadString(fh);
   string expected_header = "time,open,high,low,close,tick_volume,Upper,Mid,Lower";
   ASSERT(header_line == expected_header,
          "CSV header line matches the expected column names");

//--- Test 6: the first data row has the correct number of comma-separated fields
   string first_row = ::FileReadString(fh);
   string fields[];
   int field_count = ::StringSplit(first_row, ',', fields);
   ASSERT(field_count == 9,
          "First data row has 9 comma-separated fields");

//--- Test 7: during the warm-up period (first InpPeriod-1 bars), the
//--- Upper/Mid/Lower fields should be empty, confirming EMPTY_VALUE
//--- detection works correctly rather than writing a raw sentinel
   bool warmup_blank = (fields[6] == "" && fields[7] == "" && fields[8] == "");
   ASSERT(warmup_blank,
          "First row's indicator columns are blank during the warm-up period");

//--- read forward past the warm-up period to find a fully populated row
   string later_row = "";
   bool found_populated = false;
   for(int i = 0; i < row_count; i++)
     {
      if(::FileIsEnding(fh))
         break;
      later_row = ::FileReadString(fh);
      string later_fields[];
      int n = ::StringSplit(later_row, ',', later_fields);
      if(n == 9 && later_fields[6] != "" && later_fields[7] != "" && later_fields[8] != "")
        {
         found_populated = true;
         break;
        }
     }

//--- Test 8: a later row (past warm-up) has numeric, non-empty values
   ASSERT(found_populated,
          "A row past the warm-up period has populated indicator values");

   ::FileClose(fh);

   Print("TestIndicatorExporter: all assertions complete.");
  }
//+------------------------------------------------------------------+

The test constructs the exporter against the real SimpleBandsDemo indicator, exercising the complete pipeline, iCustom(), BarsCalculated() polling, CopyRates(), CopyBuffer(), and the CSV writer, in one pass. Tests 1 through 4 confirm the mechanical steps succeed. Test 5 confirms the header row exactly matches the requested buffer names in the requested order. Test 6 confirms the field count in a data row matches the six fixed columns plus the three buffer columns. Tests 7 and 8 are the most important: they confirm the EMPTY_VALUE handling is actually working, by checking that the very first exported row, which falls inside SimpleBandsDemo's warm-up period, has blank indicator fields, and that a later row has real numeric values once the moving average has enough history.



Reading the CSV in a Python Backtesting Pipeline

The exported file loads directly into pandas with no preprocessing:

"""
load_indicator_export.py
Loads a CSV file produced by IndicatorBufferExporter.mq5 into pandas,
with correct dtypes and warm-up period rows containing NaN.

Requirements: Python 3.7+, pandas
Install:      pip install pandas
Usage:        python load_indicator_export.py indicator_export.csv
"""

import sys
import pandas as pd


def load_indicator_export(path: str) -> pd.DataFrame:
    """
    Load an indicator export CSV into a time-indexed DataFrame.

    Parameters
    ----------
    path : str
        Path to the CSV file written by IndicatorBufferExporter.mq5.

    Returns
    -------
    pd.DataFrame
        DataFrame indexed by bar time, with OHLCV columns and every
        exported indicator buffer column as float64. Warm-up period
        rows contain NaN in the indicator columns.
    """
    df = pd.read_csv(path, parse_dates=["time"])
    df.set_index("time", inplace=True)

    # Ensure OHLC and any indicator buffer columns are float64
    for col in df.columns:
        if col != "tick_volume":
            df[col] = df[col].astype("float64")

    if "tick_volume" in df.columns:
        df["tick_volume"] = df["tick_volume"].astype("int64")

    return df


if __name__ == "__main__":
    path = sys.argv[1] if len(sys.argv) > 1 else "indicator_export.csv"

    df = load_indicator_export(path)

    print(f"Loaded {len(df)} rows from {path}")
    print(f"Columns: {list(df.columns)}")
    print()
    print("First 25 rows (warm-up period should contain NaN in indicator columns):")
    print(df.head(25))
    print()
    print("Last 10 rows:")
    print(df.tail(10))

    # Identify the indicator columns (everything after tick_volume)
    fixed_cols = {"open", "high", "low", "close", "tick_volume"}
    indicator_cols = [c for c in df.columns if c not in fixed_cols]

    if indicator_cols:
        print()
        print(f"Indicator columns detected: {indicator_cols}")
        nan_counts = df[indicator_cols].isna().sum()
        print(f"NaN count per indicator column (warm-up period length):")
        print(nan_counts)

Because parse_dates=["time"] converts the timestamp column directly to a pandas DatetimeIndex, the resulting DataFrame merges naturally with any other time-indexed data source in a backtesting framework such as backtrader, vectorbt, or a hand-rolled event loop, using a standard time-based join or simply iterating the DataFrame directly.



Extending the Exporter

The parameter and buffer limits of four and eight respectively are compile-time constants chosen to cover common cases without excessive complexity. An indicator with more than four numeric inputs would require extending the switch in CreateHandle() with additional cases, and an indicator with more than eight buffers would require enlarging m_buffer_indices, the CBufferSlot array size, and the corresponding loop bounds; both changes are mechanical repetitions of the existing pattern.

String and enumerated indicator inputs are not supported by the current parameter array, which is declared as double[4]. An indicator that takes a string parameter, such as a symbol name for a correlation indicator, would need a separate string parameter path added to Init() and a corresponding iCustom() overload added to CreateHandle().

The exporter could be extended to run automatically at the end of each trading day using EventSetTimer() inside an EA rather than as a manually triggered script, producing a continuously updated CSV file that a Python pipeline could poll for fresh data without any manual re-export step.



Limitations

CopyRates() and CopyBuffer() both depend on the terminal having the requested history already available locally. If the requested date range extends further back than the terminal's cached history for that symbol and timeframe, both calls return fewer bars than expected, and the export silently reflects only the available range rather than raising an error, since a partial history is a legitimate outcome rather than a failure condition.

The four-parameter and eight-buffer limits mean this exporter, as written, cannot directly support indicators with unusually large parameter lists or an unusually large number of output buffers without the mechanical extension described in Section 11.

The WaitForCalculation() timeout is fixed at ten seconds in Fetch(). An indicator with a very expensive internal calculation over a very long history could exceed this on a slow machine, causing the fetch to fail even though the indicator would have finished calculating shortly afterward. A production deployment exporting very long histories may need to increase this timeout.

This exporter's correctness for the warm-up period depends entirely on the indicator being exported explicitly writing EMPTY_VALUE into its own buffers during that period. Not every third-party or legacy custom indicator does this; some rely on the terminal's chart display convention rather than writing the sentinel into buffer memory directly. Exporting such an indicator may produce plausible-looking but incorrect values during its warm-up period rather than blank fields, and there is no way for the exporter to detect this from the outside. Reviewing or testing an unfamiliar indicator's warm-up behavior before relying on its exported CSV is a worthwhile precaution.

The exporter reads only the OHLC and tick volume fields from MqlRates; it does not export the spread or real_volume fields also present in that structure, since neither is commonly needed for backtests driven by indicator buffers, but both are trivially available if a specific use case requires them.


Conclusion

This article presents a complete, testable pipeline for exporting custom indicator buffer values to a CSV file that a Python backtesting environment can consume directly. CIndicatorRow defines the flat row layout that becomes one line of the exported file. CIndicatorExporter owns the full pipeline: dispatching iCustom() calls across a fixed set of parameter-count cases to work around MQL5's compile-time argument requirement, tolerating the transient negative readings BarsCalculated() can return immediately after handle creation, retrieving aligned price and buffer data with CopyRates() and CopyBuffer() using a struct-wrapped jagged array to work around MQL5's multidimensional array restrictions, and writing every value with explicit DoubleToString() formatting to avoid locale-dependent corruption. SimpleBandsDemo.mq5 provides a self-contained three-buffer indicator, written to explicitly mark its own warm-up period with EMPTY_VALUE, used to exercise and verify the entire pipeline without requiring the reader to already own a custom indicator.

The concrete operational guarantees are these: the exported CSV always begins with a header row naming every column in the order the data appears. Every price and indicator value is formatted with a guaranteed decimal point regardless of the machine's regional settings. Bars still inside an indicator's warm-up period are written as blank fields, which pandas correctly reads as NaN, rather than as a corrupted sentinel or leftover buffer memory. Buffers that return a different bar count than the price series are automatically truncated to keep every column aligned to the same bars. The honest limitations are the fixed four-parameter and eight-buffer ceilings, the dependency on locally cached terminal history, the fixed calculation-wait timeout, the reliance on the exported indicator correctly writing its own warm-up sentinel, and the omission of the spread and real_volume fields from the exported columns.


Programs used in the article:

# Name Type Description
1 IndicatorRow.mqh Include File Defines the flat struct holding one exported row's OHLCV data and up to eight indicator buffer values.
2 IndicatorExporter.mqh Include File Creates the indicator handle via a parameter-count dispatch, waits for calculation to complete, fetches aligned price and buffer data using a struct-wrapped jagged array, and writes a locale-safe CSV file.
3 SimpleBandsDemo.mq5 Custom Indicator A self-contained three-buffer moving-average band indicator that explicitly marks its own warm-up period with EMPTY_VALUE, used to exercise and verify the exporter end to end.
4 IndicatorBufferExporter.mq5 Script User-facing entry point accepting symbol, timeframe, indicator name, numeric parameters, buffer selection, date range, and output filename.
5 TestIndicatorExporter.mq5 Script Verifies the full pipeline against SimpleBandsDemo across its complete history, including confirmation that warm-up period bars are correctly written as blank CSV fields.
6 load_indicator_export.py Python Script Loads the exported CSV into a time-indexed pandas DataFrame, enforcing proper data types for price data and indicator buffers while demonstrating how the indicator's warm-up period is correctly parsed as NaN values for downstream analysis.
7 IndicatorExporter.zip Zip Archive Zip archive containing all the attached files and their paths relative to the terminal's root folder.
Neural Networks in Trading: A Cross-Domain Time Series Forecasting Framework (TimeFound) Neural Networks in Trading: A Cross-Domain Time Series Forecasting Framework (TimeFound)
In this article, we build the core of the TimeFound intelligent model step by step, adapting it to real-world time series forecasting tasks. If you are interested in the practical implementation of neural network patching algorithms in MQL5, you have come to the right place.
Building a Volume-Based Liquidity Heatmap Indicator in MQL5 Building a Volume-Based Liquidity Heatmap Indicator in MQL5
This article implements an MQL5 Liquidity Heatmap that infers likely liquidation zones from price and volume. It qualifies bars with a rolling volume SMA, computes leverage-based liquidation levels from candle extremes, ranks signals across two volume modes, and manages chart objects (lines and bubbles) that extend until price crosses them, allowing you to highlight potential stop-hunt areas and strengthen structural analysis.
How We Built the Most Powerful Machine Learning-Powered Trading Platform: The Evolution of MQL and MetaTrader Through Archives, Forums, and Releases How We Built the Most Powerful Machine Learning-Powered Trading Platform: The Evolution of MQL and MetaTrader Through Archives, Forums, and Releases
A technical history of MQL evolution: from the limited MQL and MQL II languages, through procedural MQL4, to object-oriented MQL5 with native compilation, rich APIs, and a full-fledged engineering environment. We show here the key capabilities of the language and its integrations with Python, OpenCL, ONNX, OpenBLAS, databases, DirectX, the agentic AI Assistant, and the Model Context Protocol (MCP), which connects AI systems with the terminal, MetaEditor, market data, trading operations, and development tools. This article examines archival materials on the origins of MetaQuotes and MetaTrader, the launch of MQL4.COM and MQL5.COM, the championships, Algo Forge, and their impact on the ecosystem.
Implementing and Benchmarking Bag-of-SFA-Symbols (BOSS) Against Dynamic Time Warping (DTW) Implementing and Benchmarking Bag-of-SFA-Symbols (BOSS) Against Dynamic Time Warping (DTW)
This article implements BOSS from scratch in MQL5 and applies it to regime classification: SFA turns windows into words, bags record word frequencies, and an ensemble over window lengths votes on labels. We cover the encoding steps, the BOSS distance, training with auto-generated regime labels, and practical parameters. A BTCUSD benchmark versus DTW shows higher macro accuracy on clean data and markedly faster inference.