preview
Master the Z-Score: Building Mean-Reverting MQL5 Trading Systems

Master the Z-Score: Building Mean-Reverting MQL5 Trading Systems

MetaTrader 5 — Trading systems |
282 0
Mohamed Abdelmaaboud
Mohamed Abdelmaaboud

Introduction

Many technical indicators rely on predefined thresholds or trend-following calculations, but they do not directly answer a core statistical-trading question: how unusual is the current deviation from the recent average relative to recent volatility? This is where the Z-Score becomes useful. The Z-Score expresses the distance between a value and its mean in terms of standard deviations. In trading, this provides a normalized way to examine whether price is trading relatively close to its recent average or has moved unusually far away from it.

That makes the Z-Score particularly useful when studying mean reversion, but it can also be applied to momentum and trend-following approaches. A large positive or negative Z-Score does not automatically imply a reversal; it only indicates an unusually large deviation from the recent mean. Whether it is tradable depends on the regime, timeframe, volatility, and the strategy's assumptions.

In this guide, we will build the concept from the statistical foundations through to practical algorithmic implementation in MQL5. We will cover:

The objective is to understand what the Z-Score measures, when it is useful, and how to translate that logic into a testable systematic process.

Disclaimer: This information is provided 'as is' for educational purposes only and does not constitute trading advice. No results are guaranteed. You use these materials at your own risk and remain solely responsible for any outcomes.


Understanding the Z-Score

In statistics, the Z-Score (or standard score) measures how many standard deviations an individual data point lies away from the mean (average) of its dataset. When applied to financial markets, the Z-Score measures how far the current price (or a specific indicator value) has drifted from its historical moving average over a designated lookback window. If price trades at its 20-period Simple Moving Average (SMA), its Z-Score is 0.0. If price surges far above its average, the Z-Score climbs positive (e.g., +2.0 or higher). Conversely, a deep drop pushes the Z-Score into negative territory (e.g., -2.0 or lower).

Z-Score Measurement
+3.0 Extreme Overbought (Upper Outer Band)
+2.0 Overbought Threshold (Sell Signal Area)
+1.0 Upper Deviation
0.0 Mean Line (Moving Average Target)
-1.0 Lower Deviation
-2.0 Oversold Threshold (Buy Signal Area)
-3.0  Extreme Oversold (Lower Outer Band)

Because financial assets tend to fluctuate around dynamic equilibrium points, extreme Z-Score values often mark unsustainable moves. When price stretches too far from its mean, probabilities shift toward a reversal, this is known as mean reversion.


The Mathematical Calculation

To compute the Z-Score for a financial instrument at any given price bar, we execute three distinct mathematical operations across a rolling window of length N:

1. Calculate the Simple Moving Average of the closing price over N periods:

SMA1

2. Calculate the population variance across those same N periods:

SMA2

Note: While sample variance with Bessel's correction ($N-1$) provides an unbiased estimator for sample distributions, population variance ($N$) is intentionally utilized here for computational efficiency and consistency with standard financial charting algorithms.

3. Subtract the moving average from the current closing price and divide by the standard deviation:

SMA3

Numerical Example

Suppose over a 20-period window, EURUSD has:

  • Current Close = 1.0850
  • 20-period Mean (Average) = 1.0800
  • 20-period Standard Deviation = 0.0025

ZScore-Calc

A score of +2.0 informs us that EURUSD is currently trading precisely 2 standard deviations above its 20-period mean.

Note: Calculating Z-Score directly on price measures relative distance from the rolling SMA; for strict statistical stationarity, the calculation can be applied to log-returns or detrended price oscillator residuals.


Interpreting the Z-Score

Under a standard normal distribution (Gaussian Bell Curve):

  • ~68.2% of all price observations fall between -1.0 and +1.0.
  • ~95.4% of observations fall between -2.0 and +2.0.
  • ~99.7% of observations fall between -3.0 and +3.0.

Normal_Dist

However, market returns exhibit fat tails (leptokurtosis), meaning extreme events occur more frequently than pure Gaussian distribution predicts.

Despite this reality, the empirical threshold levels used as standardized momentum benchmark triggers:

  • Zone 0 to +/- 1.0 (Equilibrium Zone): The market is in normal dynamic balance. Trend traders look for pullbacks here; mean-reversion traders stay quiet.
  • Zone +/- 1.0 to +/- 2.0 (Developing Move): Price is breaking away from historical norms. Depending on market context, this indicates either a healthy trend or an approaching reversal.
  • Zone Beyond +/- 2.0 (Extreme Stretch): Less than 5% of price distribution normally resides here during range-bound conditions. Mean-reversion systems seek short entries above +2.0 and long entries below -2.0.
Note: These Gaussian percentages serve strictly as a preliminary reference framework; financial return distributions feature fat tails, meaning extreme values occur significantly more frequently in practice.


Three Z-Score Trading Strategies

In this section, we present three Z-Score strategies and then optimize their parameters. These strategies are:

Strategy 1: Classic Mean Reversion

  • Market Regime: Ranging or consolidated markets.
  • Buy Setup: Z-Score crosses below -2.0 and then re-enters above -2.0. Exit when Z-Score crosses back to 0.0 (the mean).
  • Sell Setup: Z-Score crosses above +2.0 and then re-enters below +2.0. Exit when Z-Score returns to 0.0.
Strategy 2: Z-Score Volatility Breakout
  • Market Regime: Low-volatility squeezes transitioning to expansion.
  • Buy Setup: Z-Score surges past +2.5, signaling high momentum that breaks out of normal statistical bounds.
  • Sell Setup: Z-Score plunges past -2.5, signaling downside institutional selling pressure.
  • Exit: Exit when Z-Score reverts inside the +/- 1.0 threshold.

Strategy 3: Trend-Filtered Mean Reversion

  • Market Regime: Strongly trending markets.
  • Filter: A 200-period Exponential Moving Average (EMA) determines overall bias.
  • Buy Setup: Price is above 200 EMA (Uptrend). Buy when Z-Score dips below -1.5 (a statistically discounted entry inside an established uptrend). Exit when Z-Score reaches +1.0.
  • Sell Setup: Price is below 200 EMA (Downtrend). Short when Z-Score rises above +1.5. Exit when Z-Score reaches -1.0.


Building a Custom Z-Score Indicator in MQL5

In this section, you will find a step-by-step guide to building a custom Z-Score indicator for later use in the EAs.

#property indicator_separate_window // Render in a separate window below price
#property indicator_buffers 1       // Total dynamic buffers managed
#property indicator_plots   1       // Number of visible plotted lines

Define properties for the Z-Score Plot Line

#property indicator_type1   DRAW_LINE
#property indicator_color1  clrDodgerBlue
#property indicator_style1  STYLE_SOLID
#property indicator_width1  2

Input parameters configurable by the user - Setting lookback period (N) for SMA and Standard Deviation

input int InpPeriod = 20; // Lookback Period (N) for SMA and Standard Deviation

Global indicator buffer array

double ExtZScoreBuffer[];

In the custom indicator initialization function OnInit(), we implement the following:

  • Map the dynamic double array to the first indicator buffer index
  • Set short visual label displayed in indicator subwindow
  • Set empty line draw begin offset equal to the calculation period
  • Restrict visual decimals to 2 places
  • Validate input window length
//+------------------------------------------------------------------+
//| Custom indicator initialization function                         |
//+------------------------------------------------------------------+
int OnInit()
  {
   SetIndexBuffer(0, ExtZScoreBuffer, INDICATOR_DATA);  // Binds dynamic array to indicator buffer index 0
   PlotIndexSetString(0, PLOT_LABEL, "Z-Score");                // Sets data window label description
   PlotIndexSetInteger(0, PLOT_DRAW_BEGIN, InpPeriod);  // Offsets plotting start until sufficient bars exist
   IndicatorSetInteger(INDICATOR_DIGITS, 2);                    // Caps precision output display to 2 decimal places

   // Input Parameter Guard Clause
   if(InpPeriod <= 1)
     {
      Print("Error: Lookback period must be greater than 1."); // Log runtime error to Experts terminal tab
      return(INIT_FAILED);                             // Abort execution safely on invalid lookback
     }

   return(INIT_SUCCEEDED);                             // Initialization successful
  }

Custom indicator iteration function, through OnCalculate to code the following:

  • Ensure we have enough price bars to compute the lookback window
  • Determine starting bar index for dynamic recalculation optimization
  • Main calculation loop over bars through three steps:
    • Step 1: Calculate Mean (SMA) over the lookback window
    • Step 2: Calculate Variance and Standard Deviation
    • Step 3: Compute the Z-Score (protect against divide by zero)
  • Return total calculated bars for incremental execution on next tick
//+------------------------------------------------------------------+
//| Custom indicator iteration function                              |
//+------------------------------------------------------------------+
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[])
  {
   // Check bar history sufficiency
   if(rates_total < InpPeriod)
      return(0);                                       // Exit if total historical bars fall below window size
   // Incremental Calculation Optimization
   int start = prev_calculated - 1;                    // Recalculate only the latest/unclosed bars
   if(start < InpPeriod)
      start = InpPeriod;                               // Ensure starting index respects lookback window
   // Core Calculation Loop
   for(int i = start; i < rates_total && !IsStopped(); i++)
     {
      // Step 1: Compute Rolling Arithmetic Mean
      double sum = 0.0;                                // Accumulator for historical close prices
      for(int j = 0; j < InpPeriod; j++)
        {
         sum += close[i - j];                          // Sum close prices over lookback window
        }
      double mean = sum / InpPeriod;                   // Calculate Simple Moving Average (SMA)
      // Step 2: Compute Sum of Squared Differences
      double sum_sq_diff = 0.0;                        // Accumulator for squared deviations from mean
      for(int j = 0; j < InpPeriod; j++)
        {
         double diff = close[i - j] - mean;            // Deviation of price relative to current mean
         sum_sq_diff += diff * diff;                   // Square deviation to eliminate negative signs
        }
      // Step 3: Compute Variance & Standard Deviation
      double variance = sum_sq_diff / InpPeriod;       // Population variance formula
      double std_dev  = MathSqrt(variance);            // Standard deviation as square root of variance
      // Step 4: Normalize to Z-Score
      if(std_dev != 0.0)
         ExtZScoreBuffer[i] = (close[i] - mean) / std_dev; // Standardized Z-Score equation
      else
         ExtZScoreBuffer[i] = 0.0;                     // Zero-division safety lock for flat periods
     }
   return(rates_total);                                // Return processed bar count to optimize next tick
The full code:

//+------------------------------------------------------------------+
//|                                           Custom_Z-Score_V2.mq5  |
//+------------------------------------------------------------------+
#property indicator_separate_window // Render in a separate window below price
#property indicator_buffers 1       // Total dynamic buffers managed
#property indicator_plots   1       // Number of visible plotted lines
// Define properties for the Z-Score Plot Line
#property indicator_type1   DRAW_LINE
#property indicator_color1  clrDodgerBlue
#property indicator_style1  STYLE_SOLID
#property indicator_width1  2
input int InpPeriod = 20; // Lookback Period (N) for SMA and Standard Deviation
double ExtZScoreBuffer[];
//+------------------------------------------------------------------+
//| Custom indicator initialization function                         |
//+------------------------------------------------------------------+
int OnInit()
  {
   SetIndexBuffer(0, ExtZScoreBuffer, INDICATOR_DATA);  // Binds dynamic array to indicator buffer index 0
   PlotIndexSetString(0, PLOT_LABEL, "Custom_Z-Score_V2");                // Sets data window label description
   PlotIndexSetInteger(0, PLOT_DRAW_BEGIN, InpPeriod);  // Offsets plotting start until sufficient bars exist
   IndicatorSetInteger(INDICATOR_DIGITS, 2);                    // Caps precision output display to 2 decimal places

   // Input Parameter Guard Clause
   if(InpPeriod <= 1)
     {
      Print("Error: Lookback period must be greater than 1."); // Log runtime error to Experts terminal tab
      return(INIT_FAILED);                             // Abort execution safely on invalid lookback
     }

   return(INIT_SUCCEEDED);                             // Initialization successful
  }
//+------------------------------------------------------------------+
//| Custom indicator iteration function                              |
//+------------------------------------------------------------------+
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[])
  {
   // Check bar history sufficiency
   if(rates_total < InpPeriod)
      return(0);                                       // Exit if total historical bars fall below window size

   // Incremental Calculation Optimization
   int start = prev_calculated - 1;                    // Recalculate only the latest/unclosed bars
   if(start < InpPeriod)
      start = InpPeriod;                               // Ensure starting index respects lookback window

   // Core Calculation Loop
   for(int i = start; i < rates_total && !IsStopped(); i++)
     {
      // Step 1: Compute Rolling Arithmetic Mean
      double sum = 0.0;                                // Accumulator for historical close prices
      for(int j = 0; j < InpPeriod; j++)
        {
         sum += close[i - j];                          // Sum close prices over lookback window
        }
      double mean = sum / InpPeriod;                   // Calculate Simple Moving Average (SMA)

      // Step 2: Compute Sum of Squared Differences
      double sum_sq_diff = 0.0;                        // Accumulator for squared deviations from mean
      for(int j = 0; j < InpPeriod; j++)
        {
         double diff = close[i - j] - mean;            // Deviation of price relative to current mean
         sum_sq_diff += diff * diff;                   // Square deviation to eliminate negative signs
        }

      // Step 3: Compute Variance & Standard Deviation
      double variance = sum_sq_diff / InpPeriod;       // Population variance formula
      double std_dev  = MathSqrt(variance);            // Standard deviation as square root of variance

      // Step 4: Normalize to Z-Score
      if(std_dev != 0.0)
         ExtZScoreBuffer[i] = (close[i] - mean) / std_dev; // Standardized Z-Score equation
      else
         ExtZScoreBuffer[i] = 0.0;                     // Zero-division safety lock for flat periods
     }

   return(rates_total);                                // Return processed bar count to optimize next tick
  }//+------------------------------------------------------------------+

After compiling the code and attaching it to the chart, we can find it the same as below:

Z-Score_Chart


Coding the Expert Advisors (EAs)

In this section we will code the three strategies to execute trades automatically based on the logic of each one. The code provided presents the pure core strategy engine; production deployment requires adding spread, session time, and minimum volume filters.

Strategy 1: Z-Score Classic Mean Reversion

Z-Score < -2.0 then > -2.0 ==> Buy ==== Z-Score > 0.0 ==> Exit
Z-Score > +2.0 then < +2.0 ==> Sell ==== Z-Score < 0.0 ==> Exit

Code Steps:

Include official Trade execution class

#include <Trade\Trade.mqh>                  // Standard MQL5 trading execution library

Creates an operational object called trade that handles buy, sell, and position close requests

CTrade trade;                               // Trade object to handle order placement and exits

Input parameters:

  • Sets the period (20 candles) used by the Z-Score indicator to calculate mean and standard deviation.
  • The upper boundary (2 standard deviations above the average) for triggering short trades.
  • The lower boundary (-2 standard deviations below the average) for triggering long trades.
  • The fixed trade volume (0.1 lots) for every executed order.
  • A unique ID assigned to all trades so the EA only manages its own positions without messing with manual trades or other algorithms running on the same account.

// Strategy Input Parameters
input int    InpZPeriod     = 20;           // Lookback bars used to calculate rolling mean and std dev
input double InpUpperThresh = 2.0;          // Upper Z-Score threshold to trigger short mean-reversion entries
input double InpLowerThresh = -2.0;         // Lower Z-Score threshold to trigger long mean-reversion entries
input double InpLotSize     = 0.1;          // Fixed position sizing per trade - Fixed 0.1 lot used for baseline comparison; production deployment should utilize dynamic lot sizing based on account equity percentage risk.
input ulong  InpMagicNumber = 111001;       // Unique EA identification number to manage orders safely

Global variable to store the memory pointer (handle) for the custom indicator.

int zscore_handle;                          // Stores the memory handle for our custom Z-Score indicator

In OnInit() (called once when the EA is attached to a chart), we do the following:

  • Tags the trade handler with your unique Magic Number.
  • Requests a handle for the custom indicator named Custom_Z-Score_V2 using the chart's current symbol, timeframe, and your 20-period input.
  • Checks if MetaTrader failed to load the indicator.
    • Prints an error log to the platform Terminal if loading failed.
    • Aborts setup and detaches the EA safely.
    • Tells MetaTrader 5 everything initialized properly and the EA can start listening to market ticks.
//+------------------------------------------------------------------+
//| Initialize EA settings and connect indicator handles             |
//+------------------------------------------------------------------+
int OnInit()
  {
   trade.SetExpertMagicNumber(InpMagicNumber);                                 // Assign unique magic number to separate our EA's trades

   zscore_handle = iCustom(_Symbol, _Period, "Custom_Z-Score_V2", InpZPeriod); // Bind the compiled custom Z-Score indicator instance
   if(zscore_handle == INVALID_HANDLE)                                         // Verify indicator handle loaded successfully
     {
      Print("Failed to initialize ZScore indicator handle.");                  // Log error to Experts journal if path or handle fails
      return(INIT_FAILED);                                                     // Abort initialization to prevent executing without signals
     }

   return(INIT_SUCCEEDED);                                                     // EA setup completed without issues
  }

Expert deinitialization function through OnDeinit() to release indicator handle resources upon EA removal

//+------------------------------------------------------------------+
//| Release indicator memory allocations on system shutdown          |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
   IndicatorRelease(zscore_handle);                                         // Clean up memory buffer occupied by the indicator handle
  }

Expert tick function through OnTick() function to detect continuously every single time a new price tick hits the platform and code the following:

  • Declares a static variable that retains its value across tick updates to track bar changes.
  • Fetches the open timestamp of the current active candle.
  • Bar-close filter: If the current candle timestamp matches our last recorded bar time, it skips the rest of the execution to prevent calculating on every tick mid-bar.
  • Declares a dynamic array to hold the fetched indicator values.
  • Reverses the array index so index 0 refers to the most recently closed candle.
  • Copies 2 readings from the indicator buffer starting at bar index 1 (the last fully closed candle). If it fails to copy both, it exits early.
  • Stores the Z-Score value of the most recently closed candle.
  • Stores the Z-Score value of the candle right before that.
   // Bar-Completion Filter: Prevent intra-bar recalculations and execution churn
   static datetime last_bar_time;                                           // Tracks the open time of the last processed candle
   datetime current_bar_time = iTime(_Symbol, _Period, 0);                  // Fetch opening timestamp of the active candle
   if(current_bar_time == last_bar_time)                                    // Skip execution if current bar has already been processed
      return;

   // Fetch Calculated Indicator Values
   double z_val[];                                                          // Array buffer to hold fetched Z-Score data
   ArraySetAsSeries(z_val, true);                                           // Index 0 represents the most recently completed bar
   if(CopyBuffer(zscore_handle, 0, 1, 2, z_val) < 2)                        // Copy 2 completed bars starting from bar index 1
      return;                                                               // Exit if buffer copy returns insufficient data

   double z_current  = z_val[0];                                            // Z-Score of the last completed candle (index 1)
   double z_previous = z_val[1];                                            // Z-Score of the candle prior to last (index 2)

Auditing Open Positions:

  • Initializes state trackers before checking active account orders.
  • Iterates backward through all open trades on the account.
  • Retrieves the ticket number of the position at index i (which also selects it for property inspection).
  • Ensures the trade matches both the current chart symbol and this EA's Magic Number.
  • Increments our EA's active position count.
  • Checks whether the position is a Buy or a Sell.
  • Sets flags to track whether we currently hold a Long or Short position.
   // Monitor Active Positions
   int total_positions = 0;                                                 // Total open trades belonging to this EA
   bool is_long  = false;                                                   // Track if long position is open
   bool is_short = false;                                                   // Track if short position is open

   for(int i = PositionsTotal() - 1; i >= 0; i--)                           // Loop backward through all open platform positions
     {
      ulong ticket = PositionGetTicket(i);                                  // Select position ticket
      if(PositionGetString(POSITION_SYMBOL) == _Symbol &&                   // Ensure position matches current symbol
         PositionGetInteger(POSITION_MAGIC) == InpMagicNumber)              // Ensure position matches EA magic number
        {
         total_positions++;                                                 // Increment position count for this EA
         long type = PositionGetInteger(POSITION_TYPE);                     // Read position direction
         if(type == POSITION_TYPE_BUY)  is_long  = true;                    // Set active long flag
         if(type == POSITION_TYPE_SELL) is_short = true;                    // Set active short flag
        }
     }

Exit Logic (Take Profit at Mean):

  • If holding a Buy order and the Z-Score climbs back up to or above 0.0 (the baseline mean), the trade target is hit.
  • Calls the helper function to close only Buy positions.
  • Resets the Long position flag.
  • If holding a Sell order and the Z-Score drops back down to or below 0.0 (the baseline mean), the trade target is hit.
  • Calls the helper function (ClosePositions) to close only Sell positions.
  • Resets the Short position flag.
   // Strategy Exits: Take profit when Z-Score reverts back to the mean (0.0)
   if(is_long && z_current >= 0.0)                                          // Long profit target reached mean level
     {
      ClosePositions(POSITION_TYPE_BUY);                                    // Close active buy trade
      is_long = false;                                                      // Reset long flag
     }
   if(is_short && z_current <= 0.0)                                         // Short profit target reached mean level
     {
      ClosePositions(POSITION_TYPE_SELL);                                   // Close active sell trade
      is_short = false;                                                     // Reset short flag
     }

Entry Logic (Reversion Crosses):

  • Only looks for entry signals if there are no open trades managed by this EA.
  • Buy Signal: Checks if the Z-Score was below -2.0 on the previous candle and has now crossed back above -2.0 (rebound from oversold territory).
  • Opens a market Buy order at the Ask price with no Stop Loss or Take Profit hardcoded on the order level.
  • Updates the bar timestamp to lock out additional entries until the next candle opens.
  • Sell Signal: Checks if the Z-Score was above +2.0 on the previous candle and has now crossed back below +2.0 (reversal from overbought territory).
  • Opens a market Sell order at the Bid price.
  • Updates the bar timestamp lock.
   // Strategy Entries: Buy when recovering from oversold; Sell when cooling from overbought
   if(total_positions == 0)                                                 // Ensure market is clear of existing EA positions
     {
      if(z_previous < InpLowerThresh && z_current >= InpLowerThresh)        // Price re-entered above lower limit (Oversold Recovery)
        {
         trade.Buy(InpLotSize, _Symbol, SymbolInfoDouble(_Symbol, SYMBOL_ASK), 0, 0, "Z-Score Buy Reversion");
         last_bar_time = current_bar_time;                                  // Lock bar timestamp after execution
        }
      else if(z_previous > InpUpperThresh && z_current <= InpUpperThresh)   // Price re-entered below upper limit (Overbought Recovery)
        {
         trade.Sell(InpLotSize, _Symbol, SymbolInfoDouble(_Symbol, SYMBOL_BID), 0, 0, "Z-Score Sell Reversion");
         last_bar_time = current_bar_time;                                  // Lock bar timestamp after execution
        }
     }

Helper Function: Close positions:

  • A dedicated helper function to close open positions belonging to this EA based specifically on position type.
  • Loops backward through all active open positions on the account.
  • Gets the position ticket ID for index i .
  • Confirms the position belongs to this symbol and EA magic number.
  • Checks if the position type matches the specific directional type passed into the function.
  • Closes that specific position using its ticket number.
//+------------------------------------------------------------------+
//| Close specific position types filtered by Magic Number and Symbol|
//+------------------------------------------------------------------+
void ClosePositions(ENUM_POSITION_TYPE pos_type)
  {
   for(int i = PositionsTotal() - 1; i >= 0; i--)                           // Iterate backward through open trades pool
     {
      ulong ticket = PositionGetTicket(i);                                  // Retrieve position ticket index
      if(PositionGetString(POSITION_SYMBOL) == _Symbol &&                   // Match current asset symbol
         PositionGetInteger(POSITION_MAGIC) == InpMagicNumber)              // Match EA's designated magic number
        {
         if(PositionGetInteger(POSITION_TYPE) == pos_type)                  // Filter position direction matching target exit type
            trade.PositionClose(ticket);                                    // Submit order request to close trade ticket
        }
     }
  }
The full code:

//+------------------------------------------------------------------+
//|                                   ZScore_MeanReversion_EA-v2.mq5 |
//|                                  Copyright 2026, Quant Systems   |
//|                                             https://www.mql5.com |
//+------------------------------------------------------------------+

//+------------------------------------------------------------------+
// INCLUDES & GLOBAL PARAMETERS                                      |
//+------------------------------------------------------------------+
#include <Trade\Trade.mqh>                  // Standard MQL5 trading execution library

CTrade trade;                               // Trade object to handle order placement and exits

// Strategy Input Parameters
input int    InpZPeriod     = 20;           // Lookback bars used to calculate rolling mean and std dev
input double InpUpperThresh = 2.0;          // Upper Z-Score threshold to trigger short mean-reversion entries
input double InpLowerThresh = -2.0;         // Lower Z-Score threshold to trigger long mean-reversion entries
input double InpLotSize     = 0.1;          // Fixed position sizing per trade
input ulong  InpMagicNumber = 111001;       // Unique EA identification number to manage orders safely

// Global Variables
int zscore_handle;                          // Stores the memory handle for our custom Z-Score indicator
//+------------------------------------------------------------------+
//| Initialize EA settings and connect indicator handles             |
//+------------------------------------------------------------------+
int OnInit()
  {
   trade.SetExpertMagicNumber(InpMagicNumber);                                 // Assign unique magic number to separate our EA's trades

   zscore_handle = iCustom(_Symbol, _Period, "Custom_Z-Score_V2", InpZPeriod); // Bind the compiled custom Z-Score indicator instance
   if(zscore_handle == INVALID_HANDLE)                                         // Verify indicator handle loaded successfully
     {
      Print("Failed to initialize ZScore indicator handle.");                  // Log error to Experts journal if path or handle fails
      return(INIT_FAILED);                                                     // Abort initialization to prevent executing without signals
     }

   return(INIT_SUCCEEDED);                                                     // EA setup completed without issues
  }
//+------------------------------------------------------------------+
//| Release indicator memory allocations on system shutdown          |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
   IndicatorRelease(zscore_handle);                                            // Clean up memory buffer occupied by the indicator handle
  }
//+------------------------------------------------------------------+
//|  Core strategy execution loop triggered on every price tick      |
//+------------------------------------------------------------------+
void OnTick()
  {
   // Bar-Completion Filter: Prevent intra-bar recalculations and execution churn
   static datetime last_bar_time;                                           // Tracks the open time of the last processed candle
   datetime current_bar_time = iTime(_Symbol, _Period, 0);                  // Fetch opening timestamp of the active candle
   if(current_bar_time == last_bar_time)                                    // Skip intra-bar tick updates
      return;
   last_bar_time = current_bar_time;

   // Fetch Calculated Indicator Values
   double z_val[];                                                          // Array buffer to hold fetched Z-Score data
   ArraySetAsSeries(z_val, true);                                           // Index 0 represents the most recently completed bar
   if(CopyBuffer(zscore_handle, 0, 1, 2, z_val) < 2)                        // Copy 2 completed bars starting from bar index 1
      return;                                                               // Exit if buffer copy returns insufficient data

   double z_current  = z_val[0];                                            // Z-Score of the last completed candle (index 1)
   double z_previous = z_val[1];                                            // Z-Score of the candle prior to last (index 2)

   // Monitor Active Positions
   int total_positions = 0;                                                 // Total open trades belonging to this EA
   bool is_long  = false;                                                   // Track if long position is open
   bool is_short = false;                                                   // Track if short position is open

   for(int i = PositionsTotal() - 1; i >= 0; i--)                           // Loop backward through all open platform positions
     {
      ulong ticket = PositionGetTicket(i);                                  // Select position ticket
      if(PositionGetString(POSITION_SYMBOL) == _Symbol &&                   // Ensure position matches current symbol
         PositionGetInteger(POSITION_MAGIC) == InpMagicNumber)              // Ensure position matches EA magic number
        {
         total_positions++;                                                 // Increment position count for this EA
         long type = PositionGetInteger(POSITION_TYPE);                     // Read position direction
         if(type == POSITION_TYPE_BUY)  is_long  = true;                    // Set active long flag
         if(type == POSITION_TYPE_SELL) is_short = true;                    // Set active short flag
        }
     }

   // Strategy Exits: Take profit when Z-Score reverts back to the mean (0.0)
   if(is_long && z_current >= 0.0)                                          // Long profit target reached mean level
     {
      ClosePositions(POSITION_TYPE_BUY);                                    // Close active buy trade
      is_long = false;                                                      // Reset long flag
     }
   if(is_short && z_current <= 0.0)                                         // Short profit target reached mean level
     {
      ClosePositions(POSITION_TYPE_SELL);                                   // Close active sell trade
      is_short = false;                                                     // Reset short flag
     }

   // Strategy Entries: Buy when recovering from oversold; Sell when cooling from overbought
   if(total_positions == 0)                                                 // Ensure market is clear of existing EA positions
     {
      if(z_previous < InpLowerThresh && z_current >= InpLowerThresh)        // Price re-entered above lower limit (Oversold Recovery)
        {
         trade.Buy(InpLotSize, _Symbol, SymbolInfoDouble(_Symbol, SYMBOL_ASK), 0, 0, "Z-Score Buy Reversion");
         last_bar_time = current_bar_time;                                  // Lock bar timestamp after execution
        }
      else if(z_previous > InpUpperThresh && z_current <= InpUpperThresh)   // Price re-entered below upper limit (Overbought Recovery)
        {
         trade.Sell(InpLotSize, _Symbol, SymbolInfoDouble(_Symbol, SYMBOL_BID), 0, 0, "Z-Score Sell Reversion");
         last_bar_time = current_bar_time;                                  // Lock bar timestamp after execution
        }
     }
  }
//+------------------------------------------------------------------+
//| Close specific position types filtered by Magic Number and Symbol|
//+------------------------------------------------------------------+
void ClosePositions(ENUM_POSITION_TYPE pos_type)
  {
   for(int i = PositionsTotal() - 1; i >= 0; i--)                           // Iterate backward through open trades pool
     {
      ulong ticket = PositionGetTicket(i);                                  // Retrieve position ticket index
      if(PositionGetString(POSITION_SYMBOL) == _Symbol &&                   // Match current asset symbol
         PositionGetInteger(POSITION_MAGIC) == InpMagicNumber)              // Match EA's designated magic number
        {
         if(PositionGetInteger(POSITION_TYPE) == pos_type)                  // Filter position direction matching target exit type
            trade.PositionClose(ticket);                                    // Submit order request to close trade ticket
        }
     }
  }
//+------------------------------------------------------------------+

Testing results:

  • To compare the strategy test results, we focus on the following key metrics:
  • Net profit: It is calculated by subtracting the gross loss from the gross profit, and the highest value is the best.
  • Balance DD relative: It is the maximum loss that the account experiences during trades, and the lowest is the best.
  • Profit factor: It is the ratio of gross profit to gross loss, and the highest is the best.
  • Expected Payoff: It is the average profit or loss of a trade, and the highest value is the best.
  • Recovery factor: It measures how well the tested strategy recovers after losses, and the highest is the best.
  • Sharpe Ratio: It determines the risk and stability of the tested trading system by comparing the return with the risk-free return, and the highest Sharpe Ratio is the best.

Test conditions:

  • Initial Deposit: $30,000
  • Leverage: 1:100
  • Delays: Zero latency, ideal execution
  • Tick Model: Every tick

Notes:

  • Z-Score Provides a standardized scale to evaluate relative asset deviations, though optimal thresholds vary per asset class.
  • For simplicity, we look at net profit without normalizing by deposit, contract specification, pip value, and risk per trade but doing that could give better insights.

The testing results for the Z-Score Classic Mean Reversion strategy under the following settings:

  • Time Frame: 15-minute
  • Symbol: XAUUSD
  • Period: January 1st, 2026 to September 10th, 2026

XAUUSD1-1

XAUUSD1-2

XAUUSD1-3


Based on the above results of the testing, we can find the following important values for the test numbers:

  • Net Profit:  -7722.24 USD.
  • Balance DD relative: 32.52%.
  • Profit factor: 0.87.
  • Expected payoff: -12.24.
  • Recovery factor: -0.71.
  • Sharpe Ratio: -1.60.

Another test result for the Z-Score Classic Mean Reversion strategy under the following settings:

  • Time Frame: 4H
  • Symbol: US30
  • Period: January 1st, 2026 to September 11th, 2026

US30-1-1

US30-1-2

US30-1-3


Based on the above results of the testing, we can find the following important values for the test numbers:

  • Net Profit:  1069.93 USD.
  • Balance DD relative: 0.21%.
  • Profit factor: 7.02.
  • Expected payoff: 26.75.
  • Recovery factor: 5.09.
  • Sharpe Ratio: 5.98.

Another test results for the Z-Score Classic Mean Reversion strategy under the following settings:

  • Time Frame: 4H
  • Symbol: BTCUSD
  • Period: January 1st, 2026 to September 11th, 2026

BTCUSD-1-1

BTCUSD-1-2

BTCUSD-1-3

Based on the above results of the testing, we can find the following important values for the test numbers:

  • Net Profit:  -1789.60 USD.
  • Balance DD relative: 7.38%.
  • Profit factor: 0.64.
  • Expected payoff: -36.52.
  • Recovery factor: -0.62.
  • Sharpe Ratio: -1.33.

Strategy 2: Z-Score Volatility Breakout

Z-Score > +2.5 ==> high momentum ==> Buy ==== Z-Score < +1 ==> Exit
Z-Score < -2.5 ==> selling pressure ==> Sell ==== Z-Score > -1 ==> Exit

Strategy 2 triggers entries when the Z-Score breaks out beyond statistical deviation thresholds (+/- 2.5), capturing momentum expansion.

The full code:

//+------------------------------------------------------------------+
//|                                      ZScore_Breakout_EV-v2.mq5   |
//|                                  Copyright 2026, Quant Systems   |
//|                                             https://www.mql5.com |
//+------------------------------------------------------------------+
//+------------------------------------------------------------------+
//| INCLUDES & GLOBAL PARAMETERS                                     |
//+------------------------------------------------------------------+
#include <Trade\Trade.mqh>                  // Standard MQL5 trading execution library

CTrade trade;                               // Instantiates global trade management object

// Strategy Input Parameters
input int    InpZPeriod       = 20;         // Rolling lookback window for the statistical calculation
input double InpBreakoutUpper = 2.5;        // Upper deviation threshold to trigger momentum long entries
input double InpBreakoutLower = -2.5;       // Lower deviation threshold to trigger momentum short entries
input double InpExitThreshold = 1.0;        // Exit boundary when expansion momentum cools back down
input double InpLotSize       = 0.1;        // Trade volume allocated per order
input ulong  InpMagicNumber   = 111002;     // EA ticket ID tag for isolated trade management

// Global Variables
int zscore_handle;                          // Memory pointer handle for the custom Z-Score indicator
//+------------------------------------------------------------------+
//| Initialize EA configurations and validate indicator links        |
//+------------------------------------------------------------------+
int OnInit()
  {
   trade.SetExpertMagicNumber(InpMagicNumber);                                 // Assign magic number tag to distinguish EA trades

   zscore_handle = iCustom(_Symbol, _Period, "Custom_Z-Score_V2", InpZPeriod); // Bind compiled custom indicator with parameter
   if(zscore_handle == INVALID_HANDLE)                                         // Guard clause to catch path or loading failure
      return(INIT_FAILED);                                                     // Terminate initialization safely

   return(INIT_SUCCEEDED);                                                     // System ready for tick processing
  }
//+------------------------------------------------------------------+
//| Clean up memory handles on EA uninitialization                   |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
   IndicatorRelease(zscore_handle);                                         // Free up memory resources allocated to the handle
  }
//+------------------------------------------------------------------+
//| Main strategy execution handler called on every tick event       |
//+------------------------------------------------------------------+
void OnTick()
  {
   // Bar-Completion Filter: Prevent intra-bar noise and false triggers
   static datetime last_bar_time;                                           // Static variable preserving last processed bar timestamp
   datetime current_bar_time = iTime(_Symbol, _Period, 0);                  // Fetch open timestamp of current candle
   if(current_bar_time == last_bar_time)                                    // Skip intra-bar tick updates
      return;
   last_bar_time = current_bar_time;

   // Fetch Calculated Indicator Values
   double z_val[];                                                          // Array buffer for retrieving calculated Z-Score values
   ArraySetAsSeries(z_val, true);                                           // Index 0 set as newest completed bar
   if(CopyBuffer(zscore_handle, 0, 1, 2, z_val) < 2)                       // Pull last 2 completed bars starting from index 1
      return;                                                               // Exit tick cycle if array buffer fails to populate

   double z_current  = z_val[0];                                            // Z-Score of the most recently closed candle
   double z_previous = z_val[1];                                            // Z-Score of the candle preceding it

   // Audit Active Positions
   int total_positions = 0;                                                 // Counter for active EA trades
   bool is_long  = false;                                                   // Dynamic state tracking active buy orders
   bool is_short = false;                                                   // Dynamic state tracking active sell orders

   for(int i = PositionsTotal() - 1; i >= 0; i--)                           // Scan open position pool backward
     {
      ulong ticket = PositionGetTicket(i);                                  // Retrieve position ticket handle
      if(PositionGetString(POSITION_SYMBOL) == _Symbol &&                   // Confirm position belongs to current instrument
         PositionGetInteger(POSITION_MAGIC) == InpMagicNumber)              // Confirm position belongs to this EA
        {
         total_positions++;                                                 // Increment open EA positions tally
         long type = PositionGetInteger(POSITION_TYPE);                     // Retrieve trade order direction
         if(type == POSITION_TYPE_BUY)  is_long  = true;                    // Flag active long position state
         if(type == POSITION_TYPE_SELL) is_short = true;                    // Flag active short position state
        }
     }

   // Strategy Exits: Close momentum position when deviation falls below exit line
   if(is_long && z_current < InpExitThreshold)                              // Long momentum exhausted below upper threshold
     {
      ClosePositions(POSITION_TYPE_BUY);                                                     // Flatten open long positions
      is_long = false;                                                      // Reset long state flag
     }
   if(is_short && z_current > -InpExitThreshold)                            // Short momentum exhausted above lower threshold
     {
      ClosePositions(POSITION_TYPE_SELL);                                                     // Flatten open short positions
      is_short = false;                                                     // Reset short state flag
     }

   // Strategy Entries: Execute breakout trades when price surges past statistical boundaries
   if(total_positions == 0)                                                 // Ensure zero open exposure before trade entry
     {
      if(z_previous < InpBreakoutUpper && z_current >= InpBreakoutUpper)    // Upside breakout: Z-Score crosses above +2.5
        {
         trade.Buy(InpLotSize, _Symbol, SymbolInfoDouble(_Symbol, SYMBOL_ASK), 0, 0, "Z-Breakout Buy");
         last_bar_time = current_bar_time;                                  // Lock timestamp to prevent duplicate entries on bar
        }
      else if(z_previous > InpBreakoutLower && z_current <= InpBreakoutLower) // Downside breakout: Z-Score plunges below -2.5
        {
         trade.Sell(InpLotSize, _Symbol, SymbolInfoDouble(_Symbol, SYMBOL_BID), 0, 0, "Z-Breakout Sell");
         last_bar_time = current_bar_time;                                  // Lock timestamp to prevent duplicate entries on bar
        }
     }
  }
//+------------------------------------------------------------------------+
//| Close all active trades associated with this EA magic number and symbol|
//+------------------------------------------------------------------------+
void ClosePositions(ENUM_POSITION_TYPE pos_type)
  {
   for(int i = PositionsTotal() - 1; i >= 0; i--)                           // Iterate through open platform positions backward
     {
      ulong ticket = PositionGetTicket(i);                                  // Retrieve position ticket index
      if(PositionGetString(POSITION_SYMBOL) == _Symbol &&                   // Match symbol filter
         PositionGetInteger(POSITION_MAGIC) == InpMagicNumber)              // Match EA magic number filter	 
        {
         if(PositionGetInteger(POSITION_TYPE) == pos_type)                  
            trade.PositionClose(ticket);                                         
        }
     }
  }
//+------------------------------------------------------------------+

Code differences:

Inputs:

  • Threshold Values & Purpose: uses wider limits (+/- 2.5) to catch explosive breakout moves.
  • Exit Parameters: Introduces an explicit InpExitThreshold parameter set at 1.0 to lock in profits when momentum cools down.
  • Magic Number: uses 111002.
// Strategy Input Parameters
input int    InpZPeriod       = 20;         // Rolling lookback window for the statistical calculation
input double InpBreakoutUpper = 2.5;        // Upper deviation threshold to trigger momentum long entries
input double InpBreakoutLower = -2.5;       // Lower deviation threshold to trigger momentum short entries
input double InpExitThreshold = 1.0;        // Exit boundary when expansion momentum cools back down
input double InpLotSize       = 0.1;        // Trade volume allocated per order - no change
input ulong  InpMagicNumber   = 111002;     // EA ticket ID tag for isolated trade management
Exit Rules (OnTick):
  • Long Exits: exits a Buy trade when momentum drops back down below the exit threshold (< +1.0).
  • Short Exits: exits a Sell trade when momentum recovers back above the negative threshold (> -1.0).

   // Strategy Exits: Close momentum position when deviation falls below exit line
   if(is_long && z_current < InpExitThreshold)                              // Long momentum exhausted below upper threshold
     {
      ClosePositions(POSITION_TYPE_BUY);                                                     // Flatten open long positions
      is_long = false;                                                      // Reset long state flag
     }
   if(is_short && z_current > -InpExitThreshold)                            // Short momentum exhausted above lower threshold
     {
      ClosePositions(POSITION_TYPE_SELL);                                                     // Flatten open short positions
      is_short = false;                                                     // Reset short state flag
     }

Entry Rules (OnTick):

  • Buy Condition: buys when price moves up through +2.5 (breaking out into a strong upward trend).
  • Sell Condition: sells when price moves down through -2.5 (breaking out into a strong downward trend).
  • Order Comments: tags orders as "Z-Breakout Buy/Sell".

   // Strategy Entries: Execute breakout trades when price surges past statistical boundaries
   if(total_positions == 0)                                                 // Ensure zero open exposure before trade entry
     {
      if(z_previous < InpBreakoutUpper && z_current >= InpBreakoutUpper)    // Upside breakout: Z-Score crosses above +2.5
        {
         trade.Buy(InpLotSize, _Symbol, SymbolInfoDouble(_Symbol, SYMBOL_ASK), 0, 0, "Z-Breakout Buy");
         last_bar_time = current_bar_time;                                  // Lock timestamp to prevent duplicate entries on bar
        }
      else if(z_previous > InpBreakoutLower && z_current <= InpBreakoutLower) // Downside breakout: Z-Score plunges below -2.5
        {
         trade.Sell(InpLotSize, _Symbol, SymbolInfoDouble(_Symbol, SYMBOL_BID), 0, 0, "Z-Breakout Sell");
         last_bar_time = current_bar_time;                                  // Lock timestamp to prevent duplicate entries on bar
        }
     }
  }

The testing results for the Z-Score Volatility Breakout strategy as per the rules of:

  • Time Frame: 15-minute
  • Symbol: XAUUSD
  • Period: January 1st, 2026 to September 10th, 2026

XAUUSD2-1

XAUUSD2-2

XAUUSD2-3

Based on the above results of the testing, we can find the following important values for the test numbers:

  • Net Profit:  5982.18 USD.
  • Balance DD relative: 6.43%.
  • Profit factor: 1.18.
  • Expected payoff: 14.28.
  • Recovery factor: 1.59.
  • Sharpe Ratio: 2.38.

Another test results for the Z-Score Volatility Breakout strategy as per the rules of:

  • Time Frame: 4H
  • Symbol: US30
  • Period: January 1st, 2026 to September 11th, 2026

US30-2-1

US30-2-2

US30-2-3

Based on the above results of the testing, we can find the following important values for the test numbers:

  • Net Profit:  -348.06 USD.
  • Balance DD relative: 1.21%.
  • Profit factor: 0.18.
  • Expected payoff: -14.50.
  • Recovery factor: -0.82.
  • Sharpe Ratio: -5.00.

Another test results for the Z-Score Volatility Breakout strategy as per the rules of:

  • Time Frame: 4H
  • Symbol: BTCUSD
  • Period: January 1st, 2026 to September 11th, 2026

 BTCUSD-2-1

BTCUSD-2-2

BTCUSD-2-3

Based on the above results of the testing, we can find the following important values for the test numbers:

  • Net Profit:  1283.43 USD.
  • Balance DD relative: 3.65%.
  • Profit factor: 1.61.
  • Expected payoff: 36.67.
  • Recovery factor: 0.67.
  • Sharpe Ratio: 2.13.

Strategy 3: Z-Score Trend-Filtered

Price > 200EMA & Z-Score < -1.5 && Z-Score > -1.5 ==> Buy ==== Z-Score > +1 ==> Exit
Price < 200EMA & Z-Score > +1.5 && Z-Score < +1.5 ==> Sell ==== Z-Score < -1 ==> Exit

  • Market Regime: Trend-following pullback entries using EMA200 as a trend-direct filter.
  • Filter: A 200-period Exponential Moving Average (EMA) determines overall bias.
  • Buy Setup: Price is above 200 EMA (Uptrend). Buy when Z-Score dips below -1.5 & Z-Score crosses back above -1.5 (a statistically discounted entry inside an established uptrend). Exit when Z-Score reaches +1.0.
  • Sell Setup: Price is below 200 EMA (Downtrend). Short when Z-Score rises above +1.5 &  Z-Score dips below +1.5. Exit when Z-Score reaches -1.0.

The full code:

//+------------------------------------------------------------------+
//|                                  ZScore_TrendFilter_EA-v2.mq5    |
//|                                  Copyright 2026, Quant Systems   |
//|                                             https://www.mql5.com |
//+------------------------------------------------------------------+
//+------------------------------------------------------------------+
//| Structuring core inputs, strategy thresholds, and trade handles  |
//+------------------------------------------------------------------+
#include <Trade\Trade.mqh>                  // Standard MQL5 trade execution library

CTrade trade;                               // Global CTrade instance for order routing

// Strategy Input Parameters
input int    InpZPeriod      = 20;          // Lookback period for Z-Score statistical window
input int    InpEMAPeriod    = 200;         // Baseline EMA period used to determine overall macro trend
input double InpBuyPullback  = -1.5;        // Z-Score threshold to trigger long entry in an uptrend
input double InpSellPullback = 1.5;         // Z-Score threshold to trigger short entry in a downtrend
input double InpLotSize      = 0.1;         // Fixed lot sizing allocated per order execution
input ulong  InpMagicNumber  = 111003;      // Unique identifier tag to manage position allocation

// Global Handles
int zscore_handle;                          // Custom Z-Score indicator pointer handle
int ema_handle;                             // Moving Average indicator pointer handle
//+----------------------------------------------------------------------+
//| Initialization sequence to configure magic number and indicator links|
//+----------------------------------------------------------------------+
int OnInit()
  {
   trade.SetExpertMagicNumber(InpMagicNumber);                               // Assign magic number tag to isolate trades

   // Load indicator handles for runtime data fetching
   zscore_handle = iCustom(_Symbol, _Period, "Custom_Z-Score_V2", InpZPeriod);
   ema_handle    = iMA(_Symbol, _Period, InpEMAPeriod, 0, MODE_EMA, PRICE_CLOSE);

   // Validate handle creation to prevent runtime errors
   if(zscore_handle == INVALID_HANDLE || ema_handle == INVALID_HANDLE)
     {
      Print("Failed to load indicator handles.");                            // Log error to Experts journal
      return(INIT_FAILED);                                                   // Abort initialization safely
     }

   return(INIT_SUCCEEDED);                                                   // Setup successfully verified
  }
//+------------------------------------------------------------------+
//| Clean-up routine to release allocated indicator handles on remove|
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
   IndicatorRelease(zscore_handle);                                         // Free custom indicator memory
   IndicatorRelease(ema_handle);                                            // Free EMA indicator memory allocation
  }
//+------------------------------------------------------------------+
//| Core strategy execution loop triggered on every price quote tick |
//+------------------------------------------------------------------+
void OnTick()
  {
   // Bar-Completion Control: Restrict strategy evaluation to closed candles
   static datetime last_bar_time;                                           // Persistent timestamp tracker
   datetime current_bar_time = iTime(_Symbol, _Period, 0);                  // Obtain current candle open time
   if(current_bar_time == last_bar_time) return;                            // Skip intra-bar tick updates
   last_bar_time = current_bar_time;

   // Buffer Setup and Data Retrieval
   double z_val[];                                                          // Array buffer for Z-Score readings
   double ema_val[];                                                        // Array buffer for EMA baseline
   double close_val[];                                                      // Array buffer for historical close prices

   ArraySetAsSeries(z_val, true);                                           // Reverse index ordering (0 = newest closed)
   ArraySetAsSeries(ema_val, true);
   ArraySetAsSeries(close_val, true);

   // Copy calculated indicator and bar values into local buffers
   if(CopyBuffer(zscore_handle, 0, 1, 2, z_val) < 2) return;                 // Read last 2 closed Z-Score values
   if(CopyBuffer(ema_handle, 0, 1, 1, ema_val) < 1) return;                  // Read latest closed EMA value
   if(CopyClose(_Symbol, _Period, 1, 1, close_val) < 1) return;              // Read latest closed price bar

   double z_current   = z_val[0];                                            // Most recently completed candle Z-Score
   double z_previous  = z_val[1];                                            // Candle preceding the most recent closed
   double current_ema = ema_val[0];                                          // Current EMA trendline value
   double close_price = close_val[0];                                        // Closed price of the evaluated bar

   // Audit Existing EA Positions
   int total_positions = 0;                                                 // Counter for active positions owned by EA
   bool is_long  = false;                                                   // Flag tracking active buy state
   bool is_short = false;                                                   // Flag tracking active sell state

   for(int i = PositionsTotal() - 1; i >= 0; i--)                           // Scan active platform positions backwards
     {
      ulong ticket = PositionGetTicket(i);                                  // Retrieve order ticket ID
      if(PositionGetString(POSITION_SYMBOL) == _Symbol &&                   // Verify current chart symbol matches
         PositionGetInteger(POSITION_MAGIC) == InpMagicNumber)              // Verify EA magic number matches
        {
         total_positions++;                                                 // Increment position counter
         long type = PositionGetInteger(POSITION_TYPE);                     // Retrieve position direction
         if(type == POSITION_TYPE_BUY)  is_long  = true;                    // Long trade active
         if(type == POSITION_TYPE_SELL) is_short = true;                    // Short trade active
        }
     }

   // Dynamic Position Exits: Take profit when reversion reaches opposite boundary
   if(is_long && z_current >= 1.0)                                          // Long profit target reached (+1.0 standard dev)
     {
      ClosePositions(POSITION_TYPE_BUY);                                                     // Exit active long positions
      is_long = false;                                                      // Reset state flag
     }
   if(is_short && z_current <= -1.0)                                        // Short profit target reached (-1.0 standard dev)
     {
      ClosePositions(POSITION_TYPE_SELL);                                                     // Exit active short positions
      is_short = false;                                                     // Reset state flag
     }

   // Strategy Entry Logic: Trend-aligned pullback execution
   if(total_positions == 0)                                                 // Confirm no exposure before entering
     {
      // Long Entry: Macro Uptrend (Price > 200 EMA) + Oversold Pullback Rebound (Z-Score crosses back above -1.5)
      if(close_price > current_ema && z_previous < InpBuyPullback && z_current >= InpBuyPullback)
        {
         trade.Buy(InpLotSize, _Symbol, SymbolInfoDouble(_Symbol, SYMBOL_ASK), 0, 0, "Z-Trend Pullback Buy");
         last_bar_time = current_bar_time;                                  // Lock bar time to prevent double entry
        }
      // Short Entry: Macro Downtrend (Price < 200 EMA) + Overbought Pullback Rebound (Z-Score crosses back below +1.5)
      else if(close_price < current_ema && z_previous > InpSellPullback && z_current <= InpSellPullback)
        {
         trade.Sell(InpLotSize, _Symbol, SymbolInfoDouble(_Symbol, SYMBOL_BID), 0, 0, "Z-Trend Pullback Sell");
         last_bar_time = current_bar_time;                                  // Lock bar time to prevent double entry
        }
     }
  }
//+-------------------------------------------------------------------+
//| Iterates and flattens all open positions matching symbol and magic|
//+-------------------------------------------------------------------+
void ClosePositions(ENUM_POSITION_TYPE pos_type)
  {
   for(int i = PositionsTotal() - 1; i >= 0; i--)                           // Loop through open positions in reverse order
     {
      ulong ticket = PositionGetTicket(i);                                  // Read position ticket number
      if(PositionGetString(POSITION_SYMBOL) == _Symbol &&                   // Ensure symbol matches current chart
         PositionGetInteger(POSITION_MAGIC) == InpMagicNumber &&              // Ensure magic number matches EA instance
	 PositionGetInteger(POSITION_TYPE) == pos_type)
        {
         trade.PositionClose(ticket);                                       // Execute position closure
        }
     }
  }
//+------------------------------------------------------------------+

Differences

Input Parameters:

  • Introduces InpEMAPeriod (200) for trend determination, alongside InpBuyPullback (-1.5) and InpSellPullback (1.5) to enter dips/rallies within the broader trend.
  • Magic Numbers: 111003

// Strategy Input Parameters
input int    InpZPeriod      = 20;          // Lookback period for Z-Score statistical window - no change
input int    InpEMAPeriod    = 200;         // Baseline EMA period used to determine overall macro trend
input double InpBuyPullback  = -1.5;        // Z-Score threshold to trigger long entry in an uptrend
input double InpSellPullback = 1.5;         // Z-Score threshold to trigger short entry in a downtrend
input double InpLotSize      = 0.1;         // Fixed lot sizing allocated per order execution - no change
input ulong  InpMagicNumber  = 111003;      // Unique identifier tag to manage position allocation

Indicator Handles & Initialization (OnInit/OnDeinit):

  • Handle Variables: Declares two handles: zscore_handle and ema_handle.
  • Initialization (OnInit): Calls iCustom() for Z-Score and adds iMA() to initialize a 200-period Exponential Moving Average (MODE_EMA, PRICE_CLOSE). It also validates both handles in its if check.
  • Deinitialization (OnDeinit): Releases both zscore_handle and ema_handle.

// Global Handles
int zscore_handle;                          // Custom Z-Score indicator pointer handle
int ema_handle;                             // Moving Average indicator pointer handle
//+----------------------------------------------------------------------+
//| Initialization sequence to configure magic number and indicator links|
//+----------------------------------------------------------------------+
int OnInit()
  {
   trade.SetExpertMagicNumber(InpMagicNumber);                               // Assign magic number tag to isolate trades

   // Load indicator handles for runtime data fetching
   zscore_handle = iCustom(_Symbol, _Period, "Custom_Z-Score_V2", InpZPeriod);
   ema_handle    = iMA(_Symbol, _Period, InpEMAPeriod, 0, MODE_EMA, PRICE_CLOSE);

   // Validate handle creation to prevent runtime errors
   if(zscore_handle == INVALID_HANDLE || ema_handle == INVALID_HANDLE)
     {
      Print("Failed to load indicator handles.");                            // Log error to Experts journal
      return(INIT_FAILED);                                                   // Abort initialization safely
     }

   return(INIT_SUCCEEDED);                                                   // Setup successfully verified
  }
//+------------------------------------------------------------------+
//| Clean-up routine to release allocated indicator handles on remove|
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
   IndicatorRelease(zscore_handle);                                         // Free custom indicator memory
   IndicatorRelease(ema_handle);                                            // Free EMA indicator memory allocation
  }

Data Buffers & Data Fetching (OnTick):

  • Buffer Arrays: Declares three arrays—z_val[], ema_val[], and close_val[] and sets all three as time series (ArraySetAsSeries).
  • Copying Data: Calls CopyBuffer() for Z-Score, CopyBuffer() for the EMA, and CopyClose() to retrieve the latest completed bar's close price for trend comparisons.
   // Buffer Setup and Data Retrieval
   double z_val[];                                                          // Array buffer for Z-Score readings
   double ema_val[];                                                        // Array buffer for EMA baseline
   double close_val[];                                                      // Array buffer for historical close prices

   ArraySetAsSeries(z_val, true);                                           // Reverse index ordering (0 = newest closed)
   ArraySetAsSeries(ema_val, true);
   ArraySetAsSeries(close_val, true);

   // Copy calculated indicator and bar values into local buffers
   if(CopyBuffer(zscore_handle, 0, 1, 2, z_val) < 2) return;                 // Read last 2 closed Z-Score values
   if(CopyBuffer(ema_handle, 0, 1, 1, ema_val) < 1) return;                  // Read latest closed EMA value
   if(CopyClose(_Symbol, _Period, 1, 1, close_val) < 1) return;              // Read latest closed price bar

Trade Exit Logic (Trend Filter):

  • Closes long trades when the Z-Score expands to a +1.0 standard deviation target (z_current >= 1.0).
  • Closes short trades when the Z-Score expands down to a -1.0 standard deviation target (z_current <= -1.0).
  • Calls ClosePositions() without arguments to liquidate open trades.

   // Dynamic Position Exits: Take profit when reversion reaches opposite boundary
   if(is_long && z_current >= 1.0)                                          // Long profit target reached (+1.0 standard dev)
     {
      ClosePositions(POSITION_TYPE_BUY);                                                     // Exit active long positions
      is_long = false;                                                      // Reset state flag
     }
   if(is_short && z_current <= -1.0)                                        // Short profit target reached (-1.0 standard dev)
     {
      ClosePositions(POSITION_TYPE_SELL);                                                     // Exit active short positions
      is_short = false;                                                     // Reset state flag
     }

Trade Entry Logic (Trend-Filtered Entry):

  • Buy: Requires two conditions: price must be above the 200 EMA (close_price > current_ema) AND the Z-Score must pull back and recover (z_previous < -1.5 and z_current >= -1.5)—buying dips in an uptrend.
  • Sell: Requires two conditions: price must be below the 200 EMA (close_price < current_ema) AND the Z-Score must rally and roll over (z_previous > 1.5 and z_current <= 1.5)—selling rallies in a downtrend.
  • Comment Tags: Sets order comments to "Z-Trend Pullback Buy"/"Z-Trend Pullback Sell".

   // Strategy Entry Logic: Trend-aligned pullback execution
   if(total_positions == 0)                                                 // Confirm no exposure before entering
     {
      // Long Entry: Macro Uptrend (Price > 200 EMA) + Oversold Pullback Rebound (Z-Score crosses back above -1.5)
      if(close_price > current_ema && z_previous < InpBuyPullback && z_current >= InpBuyPullback)
        {
         trade.Buy(InpLotSize, _Symbol, SymbolInfoDouble(_Symbol, SYMBOL_ASK), 0, 0, "Z-Trend Pullback Buy");
         last_bar_time = current_bar_time;                                  // Lock bar time to prevent double entry
        }
      // Short Entry: Macro Downtrend (Price < 200 EMA) + Overbought Pullback Rebound (Z-Score crosses back below +1.5)
      else if(close_price < current_ema && z_previous > InpSellPullback && z_current <= InpSellPullback)
        {
         trade.Sell(InpLotSize, _Symbol, SymbolInfoDouble(_Symbol, SYMBOL_BID), 0, 0, "Z-Trend Pullback Sell");
         last_bar_time = current_bar_time;                                  // Lock bar time to prevent double entry
        }
     }

The testing results for the Z-Score Trend-Filtered strategy under the following settings:

  • Time Frame: 15-minute
  • Symbol: XAUUSD
  • Period: January 1st, 2026 to September 10th, 2026

XAUUSD3-1

XAUUSD3-2

XAUUSD3-3


Based on the above results of the test of a 15-minute time frame, we can find the following important values for the test numbers:

  • Net Profit:  5039.60 USD.
  • Balance DD relative: 7.20%.
  • Profit factor: 1.22.
  • Expected payoff: 17.38.
  • Recovery factor: 1.48.
  • Sharpe Ratio: 1.89.

Another set of test results for the Z-Score Trend-Filtered strategy as per the rules of:

  • Time Frame: 4H
  • Symbol: US30
  • Period: January 1st, 2026 to September 11th, 2026

US30-3-1

US30-3-2

US30-3-3

Based on the above results of the testing, we can find the following important values for the test numbers:

  • Net Profit:  582.24 USD.
  • Balance DD relative: 0.34%.
  • Profit factor: 4.65.
  • Expected payoff: 24.26.
  • Recovery factor: 2.34.
  • Sharpe Ratio: 4.07.

Another test results for the Z-Score Trend-Filtered strategy as per the rules of:

  • Time Frame: 4H
  • Symbol: BTCUSD
  • Period: January 1st, 2026 to September 11th, 2026

 BTCUSD-3-1

BTCUSD-3-2

BTCUSD-3-3

Based on the above results of the testing, we can find the following important values for the test numbers:

  • Net Profit:  1511.75 USD.
  • Balance DD relative: 1.02%.
  • Profit factor: 2.36.
  • Expected payoff: 62.99.
  • Recovery factor: 1.88.
  • Sharpe Ratio: 2.19.

The tables below show all the results in a comparable approach:

XAUUSD - 15-minute:

Metric Strategy 1: Classic Mean Reversion Strategy 2: Z-Score Volatility Breakout Strategy 3: Trend-Filtered Mean Reversion
Net Profit -$7,722.24 $5,982.18 $5,039.60
Balance DD relative 32.52% 6.43% 7.20%
Profit factor 0.87 1.18 1.22
Expected payoff -12.24 14.28 17.38
Recovery factor -0.71 1.59 1.48
Sharpe Ratio -1.60 2.38 1.89

US30 - 4H:

Metric Strategy 1: Classic Mean Reversion Strategy 2: Z-Score Volatility Breakout Strategy 3: Trend-Filtered Mean Reversion
Net Profit $1,069.93 -$348.06 $582.24
Balance DD relative 0.21% 1.21% 0.34%
Profit factor 7.02 0.18 4.65
Expected payoff 26.75 -14.50 24.726
Recovery factor 5.09 -0.82 2.34
Sharpe Ratio 5.98 -5.00 4.07

BTCUSD - 4H:

Metric Strategy 1: Classic Mean Reversion Strategy 2: Z-Score Volatility Breakout Strategy 3: Trend-Filtered Mean Reversion
Net Profit -$1,789.60 $1,283.43 $1,511.75
Balance DD relative 7.38% 3.65% 1.02%
Profit factor 0.64 1.61 2.36
Expected payoff -36.52 36.67 62.99
Recovery factor -0.62 0.67 1.88
Sharpe Ratio -1.33 2.13 2.19


Performance Summary:

  • Strategy 1 (Classic Mean Reversion): Unfiltered mean reversion gets crushed by real market trends, Gold wiped out -$7,722.24 with a painful 32.52% drawdown. The US30 result may reflect asset-specific behavior during the tested period and would require additional out-of-sample validation before treating it as a robust edge.
  • Strategy 2 (Z-Score Volatility Breakout): It printed $5982.18 on Gold and $1,283.43 on Bitcoin with manageable drawdowns, suggesting that breakout logic may work better on the higher-volatility assets tested here. Just keep it away from US30 (-$348.06 loss, terrible 0.18 PF), where it gets chopped up in ranges.
  • Strategy 3 (Trend-Filtered Mean Reversion): Adding a trend filter solved Strategy 1's drawdown problem entirely. Among the tested configurations, it was the only strategy that produced positive results across all three instruments. (Gold, US30, and Bitcoin) while keeping drawdowns under 7.20%.


        Risk Management and Practical Considerations

        While the Z-Score provides robust statistical grounding, automated trading introduces real-world mechanics you must account for:

        • The Fat Tail Hazard: Financial markets do not exhibit a pure normal distribution. Black Swan events and trend extensions can push the Z-Score to +4.0 or higher, causing significant drawdowns for unfiltered mean-reversion strategies. Always utilize explicit Stop Losses (SL) or hard equity drawdown caps. For baseline testing purposes, hard SL/TP levels are set to 0 to evaluate pure Z-Score exit signals. Production environments must define explicit stop-loss orders.
        • Lookback Period (N) Sensitivity: A short lookback period (e.g., N=10) creates a sensitive oscillator prone to false signals. A long period (e.g., N=100) smooths the output but introduces lag. A window of N=20 to N=50 offers a balanced baseline for most timeframe structures.
        • Multi-Asset Normalization: Because the Z-Score is expressed in standardized deviations rather than nominal currency values, you can compare instruments across asset classes—from Forex pairs to equity indices and crypto—using identical parameter thresholds.
        • Execution Timing: Intra-bar Z-Score values fluctuate with every tick. Always evaluate signals on completed bars (bar index 1) to eliminate false triggers caused by temporary mid-candle spikes, and implement a maximum spread filter to avoid executing during liquidity pullbacks by using the following code
           if(SymbolInfoInteger(_Symbol, SYMBOL_SPREAD) > 30) return;               // Skip if spread exceeds threshold
        
        This filter was intentionally excluded from the test code to keep the results focused on the raw Z-Score logic.


        Conclusion

        The Z-Score moves beyond traditional subjective visual charting by converting raw price fluctuations into standardized statistical metrics. By understanding how far price has deviated from its rolling average, you can construct objective systems designed for both mean-reverting ranges and high-momentum breakouts.

        By pairing the custom MQL5 indicator with the Expert Advisors provided above, you have a solid structural foundation for backtesting and deploying quantitative trading strategies on MetaTrader 5. However, transitioning from a baseline model to a production-ready system requires bridging statistical theory with real-world market mechanics.

        To take these systems further before deploying live capital, focus on three key quantitative enhancements:

        • Dynamic Volatility Sizing: Incorporate Average True Range (ATR) or historical volatility filters to dynamically adjust position size and stop-loss distance. This ensures your capital exposure scales appropriately during shifting market regimes.
        • Rigorous Optimization & Testing: Subject your parameters to walk-forward optimization and out-of-sample testing within the Strategy Tester to ensure lookback periods (N) reflect true statistical plateaus rather than over-fitted historical noise.
        • Execution & Regime Safeguards: Enforce strict execution rules such as evaluating signals exclusively on confirmed candle closes and applying spread filters to protect the system against slippage and intra-bar false triggers.

        By pairing statistical probability with systematic risk controls, you transform a basic technical indicator into a robust, institutional-grade algorithmic framework. I hope you found this article useful, and if you want to read more articles from me such as guides on building trading systems based on popular technical indicators — you can access them through my Publications page.

        You can find my attached source code files below:

        File Name Description
        Custom_Z-Score_V2.mq5 It is for the custom Z-Score indicator
        ZScore_MeanReversion_EA-v2.mq5 It is for the EA of ZScore_MeanReversion for the first strategy
        ZScore_TrendBreakout_EA-v2.mq5 It is for the EA of EA_ZScore_Breakout for the second strategy
        ZScore_TrendFiltered_EA-v2.mq5 It is for the EA of EA_ZScore_TrendFilter for the third strategy 
        MQL5.zip It contains all the attached files as per the terminal's directory


        Features of Custom Indicators Creation Features of Custom Indicators Creation
        Creation of Custom Indicators in the MetaTrader trading system has a number of features.
        Larry Williams Market Secrets (Part 18): Automating the Greatest Swing Value Breakout Strategy in MQL5 Larry Williams Market Secrets (Part 18): Automating the Greatest Swing Value Breakout Strategy in MQL5
        This article converts the Greatest Swing Value breakout rules into a configurable MQL5 Expert Advisor. It implements measurable setup conditions, failure-swing calculations, one-bar setup validity, and M1 breakout confirmation. The EA also supports broker-compatible risk management, manual or percentage-risk sizing, position control, and trade-server validation, providing a reproducible framework for independent Strategy Tester research across different markets and historical periods.
        Features of Experts Advisors Features of Experts Advisors
        Creation of expert advisors in the MetaTrader trading system has a number of features.
        MetaTrader 5 Machine Learning Blueprint (Part 22): Auditing the Selection Criterion — Measuring Overfit in Hyperparameter Search MetaTrader 5 Machine Learning Blueprint (Part 22): Auditing the Selection Criterion — Measuring Overfit in Hyperparameter Search
        We show that nested cross-validation removes optimistic evaluation bias but does not stop hyperparameter search from overfitting a finite sample, and we add a diagnostic for when the criterion cannot resolve candidates from noise. We implement OffsetPurgedKFold (boundary-shifted repartitioning), a criterion variance audit (resolution ratio and selection stability), a selection optimization curve, a corrected one-standard-error rule, noise-floor early stopping, and an effective trial count for correlated trials. The result is a dispersion report per candidate and an effective count for the Deflated Sharpe Ratio.