preview
Defining your Edge (Part 3): Using HMM and GRU in an Expert Advisor

Defining your Edge (Part 3): Using HMM and GRU in an Expert Advisor

MetaTrader 5Indicators |
213 0
Stephen Njuki
Stephen Njuki

Introduction

We continue this article series where we explore as many algorithms and neural-networks available to traders with the central theme of developing an edge. In the last article we looked at the Divergence-Mapping and Temporal-Fusion-Transformer combination, and here we introduce another pair. We maintain the approach of looking at two engines of an algorithm and a neural network so as to maximize exploration and hopefully discovery of interesting setups. Strictly speaking though, any one of these engines could be used on its own as a trading system therefore the decision to pair them is one that ought to pass sufficient scrutiny.  

When developing a trade system these days, adding a neural network to a trading algorithm is relatively easy. The hard part is showing that it improves a particular decision. Models that are elaborate can easily give one smooth backtest results while contributing little beyond the indicators and rules that are in place. This article therefore attempts to examine the problem via a custom MQL5 signal class that can be easily used as an Expert Advisor thanks to the MQL5 Wizard. In this signal class we are combining a Hidden Markov Model (HMM) with a Gated Recurrent Unit (GRU) network. We use the HMM to estimate whether the market is ranging, trending, or unusually volatile; while a GRU that will be trained for the identified regime would evaluate the recent price direction. When both are used, they would not replace any trading rules but rather they would establish if a proposed entry has merits.

This distinction gives us a testable question: Does a neural network confirmation of a specific market regime improve the admission of say trend-continuation trades on EURUSD H4 or does it only lessen the frequency of trading while repeating info already provided by ADX, DI, ATR, and price? Our method will approach the class in a similar manner as this question unfolds by: defining the trading decision; identifying a fair baseline; examining why the two engines could be suited for each other; translating their math into MQL5; and then finally confronting the backtest and forward-test results. Our goal here is not necessarily to prove why the two engines are a couple, but rather to establish any evidence that could justify that conclusion - as well as point out what evidence could have us reject this union.


Defining the Trade Decision First

To Admit or Veto a Trade

Before we can judge the HMM-GRU combo, we probably need to spot the decision it is meant to improve first. In the given tests, the input parameter 'SignalMode' is assigned 0 and this selects a continuation trend. The rule engine starts by proposing either a long or short entry from ADX and directional movement. This proposal is only able to pass when the HMM affirms the market trend regime with a posterior likelihood of at least 0.6.

When 'UseGRU' is set as 'true', a second requirement needs to be fulfilled. The GRU for the selected market regime needs to also forecast the same direction and give an output with a magnitude of at least 0.05. Disagreement, weak confidence, or a zero algorithm score can all cancel the signal. When the opening threshold ('ThresholdOpen') is set to 1, the network's most important role would not be fine-tuning the signal strength, but rather decide if a valid entry would get to be vetted by the trading framework.

What do we compare to?

A clean baseline for assessing our test results could be established by setting the input parameter 'UseGRU' to false. Everything else: symbol, timeframe, signal mode, HMM settings, money management, exits as well as test dates would remain the same. This would keep the regime filter while removing neural confirmation thus allowing us to determine if the GRU contributes anything beyond the HMM and trend rule. Another alternative comparison could be setup by removing the HMM, leaving a plain ADX/DI continuation signal. When used together, the three runs of - raw rule, HMM-gated rule, and HMM-GRU rule - would put us in a position to see where improvement, if any, gets into the pipeline.

What result would count against our thesis

Our proposed advantage needs to be questioned if enabling the GRU fails to improve expectancy, profit factor, drawdown, or recovery on comparable trades. More directly, the entries vetoed by the GRU need to perform worse than those it permits. If the rejected trades are equally profitable - or even better - the added network would not be that useful in selectivity. It would be merely making system trades less which would not be the same thing as making better forecasts.


The Potential Edge: Regime-Based Memory

State then Sequence

Our proposed edge comes from dividing one forecasting problem into smaller questions. First, we use the HMM to normalize ADX and ATR-relative volatility in order to estimate whether the market is ranging, trending, or volatile. Following this, we take one of the three small GRUs to examine recent directional sequence that is linked with this studied regime.

This gives us a "state first, sequence second" setup. Rather than asking one network to interpret all the market states, every GRU gets to specialize in behavior that is observed within a particular state. A price movement that signals continuation in an established trend could have different implications when within a quiet range. Our model tries to preserve this distinction.

What could Help?

The tests we perform are on EURUSD on the 4-hour timeframe with trend continuation chosen. For traders looking to exploit this, the potential upside is fewer entries when apparent momentum does not have a suitable regime support or recent directional persistence. Our HMM spots the environment, the algorithm proposes the trade, and the GRU challenges that proposal by using short-term memory. Our custom signal class constitutes six possible entry modes that cater for scenarios that include range reversion as well as volatility breakouts. These modes can broaden in use however they are not evidence for this test. Nonetheless, our immediate question persists albeit with more focus. Do regime-specific confirmations improve trend-continuation entry selection?

...Or is it Duplicity?

There is a credible counterargument. The HMM trading rule as well as the GRUs all get their information from ADX, DI, ATR, and price. It is thus possible that their agreement could simply be a repetition disguised as confirmation. Three specialist networks that are trained from about 80 bars could also receive too few examples especially when one appears irregularly. Finally, favorable late-2024 EURUSD trends can make the pairing appear more perceptive than it is. Our model gives us a plausible edge, however plausibility is only an admission ticket. Ideally the baseline and forward evidence do need to show that it vetoes weaker trades rather than making only fewer trades.  


The case for the HMM-GRU combo

Map, driver, and navigator

One possible interpretation of our model is to treat the HMM as a map. It helps identify the terrain-range, trend, or raised volatility. However, it does not choose direction. The chosen algorithm is the driver and it proposes the long or short trade. A GRU that is regime-specific serves as a navigator that uses recent sequence action to warn whether the proposed direction may fit on the road just ahead. This separation is the main argument for the pairing of HMM and GRU. every component gets to answer a different question. Nonetheless, separating responsibilities neatly, in theory cannot guarantee that the parts learn independent information in practice.

Could we use a Global Model?

A single GRU or LSTM could learn regimes and direction together. This approach could capture interactions that explicit HMM-GRU split does not capture. However it would require more data and this makes the origin of every decision more difficult to inspect. Even though a simple feedforward network could be cheaper to use, it would lack a recurrent state. Fixing ADX and DI thresholds can be clearer, while regime-specific logistic regression can give us a simpler confirmation layer.

The argument for this combination is therefore not that it is universally better but rather it is that HMM gives us an interpretable response to market conditions that are changing. The small GRUs do give us bounded sequence memory that can be trained natively in MQL5. On the flipside, the counter argument is blunt. If a linear classifier gives us the same vetoes, this would mean the recurrent machine is decorative plumbing.

How Complexity earns its place

An even comparison should perhaps start with the raw trend-continuation rule, to which we then add the HMM gate, and only after this do we enable the GRU. Candidate trades should ideally be logged on every stage including those that are not accepted by either model. With this three-way test, we would separate improvements resulting from regime recognition from those caused by neural confirmation.  This way, we also expose a common statistical "hack": a more selective system could exhibit lower drawdowns simply because it trades less. This combo therefore earns its complexity only if the trades it removes are consistently worse - not merely less.

The Math Contract

This model does get easier to test once its two methods (algorithm & network) are reduced to the quantities they actually compute. The HMM gives us an estimate of the present market state; the chosen GRU gets to estimate direction within that state. Neither output becomes a trade by itself. If we are to transition from observations to regime probabilities, then at bar (t), the HMM receives a two-dimensional observation:

f1

where: 

  • xt is the two-dimensional HMM observation vector for bar (t); 
  • ADX_t is the Average Directional Index, divided by 100 to scale its usual (0)–(100) range to approximately (0)–(1); 
  • ATR_t is the Average True Range; 
  • C_tis the closing price, with (|C_t|) ensuring a positive denominator; and 
  • ATR_t/|C_t| expresses volatility relative to the instrument’s price. 

The function (z.) denotes standardization over the HMM fitting window:

f2

where: 

  • (mu_y) and (sigma_y) are the window mean and standard deviation of relative ATR.

In the implementation, this standardized volatility value is clipped to the interval ([-4,4]) to limit the influence of extreme observations. The starting component stands for normalized trend strength. The second tracks volatility relative to price and then standardizes it over the fitting window. For every hidden state (i), our code processes a diagonal Gaussian emission probability defined as follows:

f3

where: 

  • b_i({x}*t) is the probability density of observing (xt) when the HMM occupies hidden state (i)
  • (i) identifies one of the three raw HMM states; and 
  • (d) indexes the two observation dimensions—normalized ADX and standardized relative ATR. 

The expression (x{td}) is the value of feature (d) at bar (t), while (mu{id}) and (sigma_{id}^{2}) are respectively the mean and variance of that feature in state (i). The constant pi is used as approximately 3.14159, while (exp[]) represents the exponential function. The forward recursion brings together the likelihood with the probability of getting from the prior state being defined as:

f4

where: 

  • (alpha_t(i)) is the forward probability for state (i) at time (t); 
  • (b_i(x*t)) is the emission density of observation (x*t) in state (i); 
  • (alpha * {t-1}(j)) is the previous forward probability for state (j); and 
  • (a_ji) is the transition probability from state (j) to state (i). The sum covers all possible preceding states (j).

A backward pass would give us the complementary probability of the left sequence. The two passes are scaled in order to avoid floating-point underflow. Their product gives us the smoothed posterior as:

f5

where:

  • (gamma_t(i)) is the smoothed posterior probability of state (i) at time (t); 
  • (alpha_t(i)) and (beta_t(i)) are its forward and backward probabilities, respectively; and 
  • the denominator normalizes their products across all states (j), ensuring that the state probabilities sum to one.

We finally use Baum-Welch re-estimation to update the starting probabilities, transition matrix, emission averages, as well as variances. When fitted, the code gives the anonymous states economic labels where by the state with the highest volatility becomes 'volatile', while the higher ADX member of the remaining pair becomes 'trend'. We treat the final state as 'range'. This labelling helps our output be more readable, but it does not guarantee that the regimes are stable. This means that a state labelled 'trend' bears the mark only because it is the only Gaussian cluster that best satisfies the class's rules for labelling.

Direction from regime-focused GRU

The active regime chooses one of the six-unit GRUs where each of the networks has a sequence of five normalized market features. Its update gate sets how much the prior state should be retained. This is definition is as follows:

f6

where:

  • (z_t) is the update-gate vector at time (t); 
  • (sigma()) is the element-wise sigmoid function, producing values between (0) and (1); 
  • (x_t) is the current input vector; (h_{t-1}) is the previous hidden state; 
  • (W_z) and (U_z) are the update gate’s input and recurrent weight matrices; and 
  • (b_z) is its bias vector. Larger values in (z_t) preserve more of the previous hidden state.

The reset gate is in charge of how much of the state gets the new candidate:

f7

where: 

  • (r_t) is the reset-gate vector at time (t); 
  • (sigma()) is the element-wise sigmoid function; 
  • (x_t) is the current input; 
  • (h_{t-1}) is the previous hidden state; and (W_r), (U_r), and (b_r) are the reset gate’s input weights, recurrent weights, and bias. The term ( h_t) is the candidate hidden state; 
  • (W_h), (U_h), and (b_h) are its corresponding parameters; 
  • (tanh()) bounds its values between (-1) and (1); 
  • and (o.) denotes element-wise multiplication. 

Thus, (r_t) determines what amount of previous-state information gets to contribute to the candidate state. The hidden state for its part is blended as:

f9

where: 

  • (h_t) is the updated hidden-state vector at time (t); 
  • (z_t) is the update-gate vector; (~h_t) is the candidate hidden state; 
  • (h_t-1) is the previous hidden state; (1-z_t) is the complementary gate; and 
  • (o.) denotes element-wise multiplication. 

Here, (z_t) controls the blend: values near (1) preserve previous information, while values near (0) favor the candidate state. A linear output that is followed by 'tanh' gets to convert this state into a bounded directional prediction. The training targets are similarly bounded using 'tanh' to the next-bar where the returned value is scaled to (0.75, ATR). This class therefore minimizes squared prediction error while using confidence-adjusted learning rates, gradient clipping, and weight decay. Training utilizes one-step clipped backpropagation instead of full backpropagation through the whole sequence. This keeps native MQL5 execution manageable, although it also weakens the argument that the GRU detects long dependencies. The hidden state moves history forward, however the gradient gets only a short rearview mirror.

Agreement, rejection, and fusion

If we let (A_t) stand for the selected algorithm score and (G_t) the GRU forecast, then the entry would be ruled out when:

f10

where: 

  • (A_t) is the algorithmic trade score at time (t), 
  • (G_t) is the GRU’s directional forecast, 
  • (|G_t|) is its absolute magnitude, and (sign()) denotes direction. 

The entry gets rejected when the algorithm gives no signal, the GRU confidence is below (0.05), or their predicted directions disagree. When the direction concurs, the code blends the algorithm's magnitude with the stronger of either the algorithm or the neural network votes. An agreeing GRU can thus keep or increase the score; It cannot weaken it slowly. Its practical influence is concentrated at the border between surviving signal and a zero signal. This is important because the tested opening  cutoff is just one. Under such a configuration therefore, the model becomes less of a precision hawk and more of a gatekeeper. 


MQL5 Implementation

Even though this custom class has more than 1500 lines of code, its execution path is actually very simple. The sequenced used is easy to identify. First we register the component with the MQL5 Wizard, prepare data, estimate the regime, workout the algorithm vote, request GRU confirmation and then return the leftover score to the Expert framework. This seems reasonably elaborate to highlight any potential fail points.

Wizard meta-data, constants, market-regimes

The header of our custom signal class starts by importing the 'CExpertSignal' base class together with ADX and ATR indicator classes. This, off the bat, keeps the implementation solely within the native MQL5 Expert framework, implying no Python processes or ONNX files, or any external training. Following this we have the typical Wizard Specification and although it is written as comments, these declarations inform MQL5 Wizard of the class name and which parameters should become inputs. They help expose the indicator periods, selected signal mode, HMM settings as well as GRU controls. The constant definitions prefixed "HMMGRU_" help define the model by listing three HMM states, two observation dimensions, five GRU inputs, and six hidden units. Fine-tuning by optimization can help change the learning window or rate, but it cannot increase the number of regimes or hidden units without editing or recompiling the class.

A pair of enumerations then prevent us from confusing regimes with trading strategies. The 'ENUM_HMMGRU_REGIME' enumeration names the range, trend, as well as volatile states while the 'ENUM_HMMGRU_SIGNAL_MODE' lists seven entry algorithms. The HMM chooses one of the three environments; 'SignalMode' chooses one of seven ways to process a trade decision. In the given test result below, this second choice is pegged at 0, trend continuation.

Class Ownership

This question is answered in the attached custom class as follows:

class CSignalHMMGRU : public CExpertSignal

Our 'CSignalHMMGRU' class owns the HMM, rule calculations, weights of the GRU, routines in training, and the final model vote. Inheriting from 'CExpertSignal' does give us access to price series, signal weighting, opening thresholds, as well as standard 'LongCondition()' and 'ShortCondition()' interface. The member variables get setup in four working groups. First, we need to hold the ADX and ATR objects:

class CSignalHMMGRU : public CExpertSignal
  {
protected:
   //--- indicator objects
   CiADX             m_adx;
   CiATR             m_atr;

   //--- indicator and signal settings
   int               m_adx_period;
   int               m_atr_period;
   int               m_signal_mode;
   bool              m_use_gru;
   int               m_hmm_window;
   int               m_hmm_iterations;
   double            m_min_regime_probability;
   double            m_trend_adx_level;
   double            m_volatility_z_level;
   int               m_price_window;
   double            m_range_atr_distance;

   //--- GRU settings
   int               m_gru_train_bars;
   int               m_gru_sequence;
   int               m_gru_epochs;
   double            m_gru_learning_rate;
   double            m_gru_min_confidence;
   double            m_gru_weight;

   //--- cached live state
   bool              m_cache_valid;
   datetime          m_cached_time;
   double            m_cached_open;
   double            m_cached_high;
   double            m_cached_low;
   double            m_cached_close;
   double            m_cached_score;
   double            m_cached_algorithm_score;
   double            m_cached_gru_score;
   int               m_current_regime;
   double            m_current_regime_probability;
   double            m_regime_probability[HMMGRU_STATES];

Then we also need to store the most recent calculations of price observed, model scores, active regime, as well as three posterior probabilities. This cache is important because the framework usually asks for a long condition and then a short condition. Without the cache, one market observation can trigger two complete HMM fits. We also track the GRU lifecycle  that sets whether training was successful, which closed bar started an update, as well as the latest training loss. This loss is an in-sample mean that is got when weights are being updated. It is not a forward-validation statistic. We then wrap up the GRU parameter banks as follows:

//--- one independent GRU parameter set per semantic regime
   double            m_wz[HMMGRU_STATES][HMMGRU_HIDDEN][HMMGRU_INPUTS];
   double            m_uz[HMMGRU_STATES][HMMGRU_HIDDEN][HMMGRU_HIDDEN];
   double            m_bz[HMMGRU_STATES][HMMGRU_HIDDEN];
   double            m_wr[HMMGRU_STATES][HMMGRU_HIDDEN][HMMGRU_INPUTS];
   double            m_ur[HMMGRU_STATES][HMMGRU_HIDDEN][HMMGRU_HIDDEN];
   double            m_br[HMMGRU_STATES][HMMGRU_HIDDEN];
   double            m_wh[HMMGRU_STATES][HMMGRU_HIDDEN][HMMGRU_INPUTS];
   double            m_uh[HMMGRU_STATES][HMMGRU_HIDDEN][HMMGRU_HIDDEN];
   double            m_bh[HMMGRU_STATES][HMMGRU_HIDDEN];
   double            m_wo[HMMGRU_STATES][HMMGRU_HIDDEN];
   double            m_bo[HMMGRU_STATES];

Every regime gets separate update-gate, reset-gate, candidate-state, and output parameters. The model is thus not an ensemble where three GRUs vote concurrently. oUR HMM gets to choose one semantic regime, and only the corresponding GRU is consulted in this. We replace or overwrite some default Wizard constructors as follows:

//--- Wizard-adjustable parameters
   void              ADXPeriod(int value)                    { m_adx_period = value;                 }
   void              ATRPeriod(int value)                    { m_atr_period = value;                 }
   void              SignalMode(int value)                   { m_signal_mode = value;                }
   void              UseGRU(bool value)                      { m_use_gru = value;                    }
   void              HMMWindow(int value)                    { m_hmm_window = value;                 }
   void              HMMIterations(int value)                { m_hmm_iterations = value;             }
   void              MinRegimeProbability(double value)      { m_min_regime_probability = value;     }
   void              TrendADXLevel(double value)             { m_trend_adx_level = value;            }
   void              VolatilityZLevel(double value)          { m_volatility_z_level = value;         }
   void              PriceWindow(int value)                  { m_price_window = value;               }
   void              RangeATRDistance(double value)          { m_range_atr_distance = value;         }
   void              GRUTrainBars(int value)                 { m_gru_train_bars = value;             }
   void              GRUSequence(int value)                  { m_gru_sequence = value;               }
   void              GRUEpochs(int value)                    { m_gru_epochs = value;                 }
   void              GRULearningRate(double value)           { m_gru_learning_rate = value;          }
   void              GRUMinConfidence(double value)          { m_gru_min_confidence = value;         }
   void              GRUWeight(double value)                 { m_gru_weight = value;                 }

This inspection method exposes the latest regime and component scores for logging or diagnosing. Finally we declare the framework that overrides some internal calculation functions. This declaration block is effectively a map of the pipeline that we do follow:

//--- standard CExpertSignal interface
   virtual bool      ValidationSettings(void);
   virtual bool      InitIndicators(CIndicators *indicators);
   virtual int       LongCondition(void);
   virtual int       ShortCondition(void);

protected:
   //--- initialization
   bool              InitADX(CIndicators *indicators, const int buffer_size);
   bool              InitATR(CIndicators *indicators, const int buffer_size);
   bool              ResizePriceSeries(const int buffer_size);

   //--- indicator and price helpers
   double            ADX(const int index)               { return(m_adx.Main(index));  }
   double            PlusDI(const int index)            { return(m_adx.Plus(index));  }
   double            MinusDI(const int index)           { return(m_adx.Minus(index)); }
   double            ATR(const int index)               { return(m_atr.Main(index));  }
   bool              IsUsable(const double value);
   bool              DataReady(const int first, const int count);
   double            Clamp(const double value, const double lower, const double upper);
   double            Clamp01(const double value);
   double            SignOf(const double value);
   double            Stronger(const double first, const double second);
   double            DirectionalIndex(const int index);
   double            ATRZScore(const int index, const int lookback);
   double            MeanClose(const int index, const int lookback);
   double            HighestPriorHigh(const int index, const int lookback);
   double            LowestPriorLow(const int index, const int lookback);

   //--- HMM estimation
   double            GaussianEmission(const double x0, const double x1,
                                      const double mean0, const double mean1,
                                      const double variance0, const double variance1);
   bool              EstimateRegime(const int offset, int &regime, double &probability,
                                    double &posterior[]);
   double            RegimeGate(const int expected_regime);

   //--- seven separate algorithm modes
   double            ModeTrendContinuation(const int index);
   double            ModeDICrossover(const int index);
   double            ModeRangeReversion(const int index);
   double            ModeVolatilityBreakout(const int index);
   double            ModeCompressionBreakout(const int index);
   double            ModePriceAction(const int index);
   double            ModeAdaptive(const int index);
   double            SelectAlgorithmMode(const int index);

   //--- GRU functions
   void              InitializeGRUWeights(void);
   double            InitialWeight(const int regime, const int gate,
                                   const int row, const int column);
   double            Sigmoid(const double value);
   double            ClipGradient(const double value);
   bool              BuildGRUFeatures(const int index, double &inputs[]);
   bool              GRUForward(const int regime, const double &inputs[],
                                double &hidden[], double &output);
   double            GRUTrainStep(const int regime, const double &inputs[],
                                  double &hidden[], const double target,
                                  const double learning_rate);
   bool              TrainGRUWindow(const int anchor, const int bars,
                                    const int epochs, const double learning_rate);
   bool              PredictGRU(const int index, const int active_regime, double &score);
   double            FuseWithGRU(const double algorithm_score, const double gru_score);

   //--- complete signal calculation and caching
   bool              SameCachedObservation(const int index);
   bool              CalculateSignal(void);

Construction and Validation

//+------------------------------------------------------------------+
//| Constructor.                                                     |
//+------------------------------------------------------------------+
CSignalHMMGRU::CSignalHMMGRU(void) :
   m_adx_period(14),
   m_atr_period(14),
   m_signal_mode(HMMGRU_MODE_TREND_CONTINUATION),
   m_use_gru(true),
   m_hmm_window(80),
   m_hmm_iterations(6),
   m_min_regime_probability(0.50),
   m_trend_adx_level(25.0),
   m_volatility_z_level(0.75),
   m_price_window(20),
   m_range_atr_distance(0.75),
   m_gru_train_bars(160),
   m_gru_sequence(10),
   m_gru_epochs(3),
   m_gru_learning_rate(0.01),
   m_gru_min_confidence(0.15),
   m_gru_weight(0.35),
   m_cache_valid(false),
   m_cached_time(0),
   m_cached_open(EMPTY_VALUE),
   m_cached_high(EMPTY_VALUE),
   m_cached_low(EMPTY_VALUE),
   m_cached_close(EMPTY_VALUE),
   m_cached_score(0.0),
   m_cached_algorithm_score(0.0),
   m_cached_gru_score(0.0),
   m_current_regime(HMMGRU_REGIME_RANGE),
   m_current_regime_probability(0.0),
   m_gru_ready(false),
   m_last_trained_bar(0),
   m_gru_training_loss(0.0)
  {
   m_used_series = USE_SERIES_OPEN + USE_SERIES_HIGH + USE_SERIES_LOW +
                   USE_SERIES_CLOSE + USE_SERIES_TIME;
   for(int state = 0; state < HMMGRU_STATES; state++)
      m_regime_probability[state] = 0.0;
   InitializeGRUWeights();
  }

//+------------------------------------------------------------------+
//| Destructor.                                                      |
//+------------------------------------------------------------------+
CSignalHMMGRU::~CSignalHMMGRU(void)
  {
  }

//+------------------------------------------------------------------+
//| Validate all adjustable settings.                                |
//+------------------------------------------------------------------+
bool CSignalHMMGRU::ValidationSettings(void)
  {
   if(!CExpertSignal::ValidationSettings())
      return(false);
   if(m_adx_period < 2 || m_atr_period < 2)
     {
      Print(__FUNCTION__ + ": ADXPeriod and ATRPeriod must be at least 2");
      return(false);
     }
   if(m_signal_mode < HMMGRU_MODE_TREND_CONTINUATION ||
      m_signal_mode > HMMGRU_MODE_ADAPTIVE)
     {
      Print(__FUNCTION__ + ": SignalMode must be between 0 and 6");
      return(false);
     }
   if(m_hmm_window < 30 || m_hmm_window > 250)
     {
      Print(__FUNCTION__ + ": HMMWindow must be between 30 and 250");
      return(false);
     }
   if(m_hmm_iterations < 1 || m_hmm_iterations > 20)
     {
      Print(__FUNCTION__ + ": HMMIterations must be between 1 and 20");
      return(false);
     }
   if(m_min_regime_probability < 0.34 || m_min_regime_probability > 0.95)
     {
      Print(__FUNCTION__ + ": MinRegimeProbability must be inputs [0.34,0.95]");
      return(false);
     }
   if(m_trend_adx_level < 5.0 || m_trend_adx_level > 60.0)
     {
      Print(__FUNCTION__ + ": TrendADXLevel must be inputs [5,60]");
      return(false);
     }
   if(m_volatility_z_level < 0.0 || m_volatility_z_level > 3.0)
     {
      Print(__FUNCTION__ + ": VolatilityZLevel must be inputs [0,3]");
      return(false);
     }
   if(m_price_window < 5 || m_price_window > 100)
     {
      Print(__FUNCTION__ + ": PriceWindow must be between 5 and 100");
      return(false);
     }
   if(m_range_atr_distance < 0.10 || m_range_atr_distance > 5.0)
     {
      Print(__FUNCTION__ + ": RangeATRDistance must be inputs [0.10,5.0]");
      return(false);
     }
   if(m_gru_train_bars < 40 || m_gru_train_bars > 800)
     {
      Print(__FUNCTION__ + ": GRUTrainBars must be between 40 and 800");
      return(false);
     }
   if(m_gru_sequence < 2 || m_gru_sequence > 32)
     {
      Print(__FUNCTION__ + ": GRUSequence must be between 2 and 32");
      return(false);
     }
   if(m_gru_epochs < 1 || m_gru_epochs > 20)
     {
      Print(__FUNCTION__ + ": GRUEpochs must be between 1 and 20");
      return(false);
     }
   if(m_gru_learning_rate <= 0.0 || m_gru_learning_rate > 0.10)
     {
      Print(__FUNCTION__ + ": GRULearningRate must be inputs (0,0.10]");
      return(false);
     }
   if(m_gru_min_confidence < 0.0 || m_gru_min_confidence > 1.0)
     {
      Print(__FUNCTION__ + ": GRUMinConfidence must be inputs [0,1]");
      return(false);
     }
   if(m_gru_weight < 0.0 || m_gru_weight > 1.0)
     {
      Print(__FUNCTION__ + ": GRUWeight must be inputs [0,1]");
      return(false);
     }
   return(true);
  }

Our constructor provides usable defaults prior to the Wizard applying any user-selected inputs. It chooses Mode-0, enables GRU use, and picks 80-bar HMM window by default. We also assign 160 bars and three epochs to the GRU training. This constructor also requests the built-in time series of open, high, low, and close to be available for use by the Expert Advisor; it clears cache and calls the GRU weight initialization function 'InitializeGRUWeights()'.

It's important for these used defaults to not be mistaken for the tested configuration. The reports we have below used ADX and ATR periods of 16, eight HMM iterations, a 0.60 regime threshold, an ADX threshold of 17.5, just 80 training GRU bars with a single epoch, and a learning rate 0.005. Our test reports below are based on these tuned parameters and not the coded defaults.

'ValidationSettings()' initially invokes the parent validation routine and then checks each custom input. Windows, periods, thresholds, epochs, weights, and learning rates all need to be verified to be within stated bounds. These checks prevent nonsensical configurations as well as unsafe array demands. They do not imply that an allowed setting is statistically sensible. For instance eighty training bars could pass input validation while still leaving one of three regime specific networks short of usable examples. The initialization of weights is not random but deterministic. This improves repeatability  where identical inputs and history start from the same weights. It also implies though that repeated tests do not reveal sensitivity to alternative initial states unless the initialization routine itself is altered.

Price series, indicators, and helpers

//+------------------------------------------------------------------+
//| Create the price series and both indicators.                     |
//+------------------------------------------------------------------+
bool CSignalHMMGRU::InitIndicators(CIndicators *indicators)
  {
   if(indicators == NULL)
      return(false);
   if(!CExpertSignal::InitIndicators(indicators))
      return(false);
   int buffer_size = m_hmm_window + m_price_window + 32;
   if(m_use_gru)
      buffer_size += m_gru_train_bars + m_gru_sequence;
   if(!ResizePriceSeries(buffer_size))
      return(false);
   if(!InitADX(indicators, buffer_size))
      return(false);
   if(!InitATR(indicators, buffer_size))
      return(false);
   m_cache_valid = false;
   m_gru_ready = false;
   m_last_trained_bar = 0;
   return(true);
  }

//+------------------------------------------------------------------+
//| Enlarge the shared standard-library price buffers.               |
//+------------------------------------------------------------------+
bool CSignalHMMGRU::ResizePriceSeries(const int buffer_size)
  {
   if(m_open == NULL || m_high == NULL || m_low == NULL ||
      m_close == NULL || m_time == NULL)
     {
      Print(__FUNCTION__ + ": required price series is not available");
      return(false);
     }
   if(m_open.BufferSize() < buffer_size && !m_open.BufferResize(buffer_size))
     {
      Print(__FUNCTION__ + ": cannot resize the open-price buffer");
      return(false);
     }
   if(m_high.BufferSize() < buffer_size && !m_high.BufferResize(buffer_size))
     {
      Print(__FUNCTION__ + ": cannot resize the high-price buffer");
      return(false);
     }
   if(m_low.BufferSize() < buffer_size && !m_low.BufferResize(buffer_size))
     {
      Print(__FUNCTION__ + ": cannot resize the low-price buffer");
      return(false);
     }
   if(m_close.BufferSize() < buffer_size && !m_close.BufferResize(buffer_size))
     {
      Print(__FUNCTION__ + ": cannot resize the close-price buffer");
      return(false);
     }
   if(m_time.BufferSize() < buffer_size && !m_time.BufferResize(buffer_size))
     {
      Print(__FUNCTION__ + ": cannot resize the time buffer");
      return(false);
     }
   return(true);
  }

//+------------------------------------------------------------------+
//| Create ADX.                                                      |
//+------------------------------------------------------------------+
bool CSignalHMMGRU::InitADX(CIndicators *indicators, const int buffer_size)
  {
   if(!indicators.Add(GetPointer(m_adx)))
     {
      Print(__FUNCTION__ + ": cannot add ADX to the indicator collection");
      return(false);
     }
   if(!m_adx.Create(m_symbol.Name(), m_period, m_adx_period))
     {
      Print(__FUNCTION__ + ": cannot create ADX");
      return(false);
     }
   if(!m_adx.BufferResize(buffer_size))
     {
      Print(__FUNCTION__ + ": cannot resize the ADX buffer");
      return(false);
     }
   return(true);
  }

//+------------------------------------------------------------------+
//| Create ATR.                                                      |
//+------------------------------------------------------------------+
bool CSignalHMMGRU::InitATR(CIndicators *indicators, const int buffer_size)
  {
   if(!indicators.Add(GetPointer(m_atr)))
     {
      Print(__FUNCTION__ + ": cannot add ATR to the indicator collection");
      return(false);
     }
   if(!m_atr.Create(m_symbol.Name(), m_period, m_atr_period))
     {
      Print(__FUNCTION__ + ": cannot create ATR");
      return(false);
     }
   if(!m_atr.BufferResize(buffer_size))
     {
      Print(__FUNCTION__ + ": cannot resize the ATR buffer");
      return(false);
     }
   return(true);
  }

//+------------------------------------------------------------------+
//| Numeric validity check.                                          |
//+------------------------------------------------------------------+
bool CSignalHMMGRU::IsUsable(const double value)
  {
   return(value != EMPTY_VALUE && MathIsValidNumber(value));
  }

//+------------------------------------------------------------------+
//| Verify that a complete observation window is available.          |
//+------------------------------------------------------------------+
bool CSignalHMMGRU::DataReady(const int first, const int count)
  {
   if(first < 0 || count < 1)
      return(false);
   int last = first + count - 1;
   if(last >= m_open.BufferSize() || last >= m_high.BufferSize() ||
      last >= m_low.BufferSize() || last >= m_close.BufferSize() ||
      last >= m_adx.BufferSize() || last >= m_atr.BufferSize())
      return(false);
   if(!IsUsable(Open(first)) || !IsUsable(High(first)) ||
      !IsUsable(Low(first)) || !IsUsable(Close(first)) ||
      !IsUsable(ADX(first)) || !IsUsable(PlusDI(first)) ||
      !IsUsable(MinusDI(first)) || !IsUsable(ATR(first)))
      return(false);
   if(!IsUsable(Close(last)) || !IsUsable(ADX(last)) ||
      !IsUsable(PlusDI(last)) || !IsUsable(MinusDI(last)) ||
      !IsUsable(ATR(last)))
      return(false);
   return(true);
  }

//+------------------------------------------------------------------+
//| Clamp a number.                                                  |
//+------------------------------------------------------------------+
double CSignalHMMGRU::Clamp(const double value, const double lower,
                            const double upper)
  {
   if(value < lower)
      return(lower);
   if(value > upper)
      return(upper);
   return(value);
  }

//+------------------------------------------------------------------+
//| Clamp to [0,1].                                                  |
//+------------------------------------------------------------------+
double CSignalHMMGRU::Clamp01(const double value)
  {
   return(Clamp(value, 0.0, 1.0));
  }

//+------------------------------------------------------------------+
//| Return the sign of a value.                                      |
//+------------------------------------------------------------------+
double CSignalHMMGRU::SignOf(const double value)
  {
   if(value > 0.0)
      return(1.0);
   if(value < 0.0)
      return(-1.0);
   return(0.0);
  }

//+------------------------------------------------------------------+
//| Return the score with the greater absolute magnitude.            |
//+------------------------------------------------------------------+
double CSignalHMMGRU::Stronger(const double first, const double second)
  {
   if(MathAbs(first) >= MathAbs(second))
      return(first);
   return(second);
  }

//+------------------------------------------------------------------+
//| Normalized +DI/-DI imbalance.                                    |
//+------------------------------------------------------------------+
double CSignalHMMGRU::DirectionalIndex(const int index)
  {
   double plus = PlusDI(index);
   double minus = MinusDI(index);
   if(!IsUsable(plus) || !IsUsable(minus))
      return(0.0);
   return(Clamp((plus - minus) / (plus + minus + HMMGRU_EPS), -1.0, 1.0));
  }

//+------------------------------------------------------------------+
//| ATR percentage z-score using only the current and older bars.    |
//+------------------------------------------------------------------+
double CSignalHMMGRU::ATRZScore(const int index, const int lookback)
  {
   if(lookback < 5 || !DataReady(index, lookback))
      return(EMPTY_VALUE);
   double mean = 0.0;
   int valid = 0;
   for(int i = 0; i < lookback; i++)
     {
      double close = Close(index + i);
      double atr  = ATR(index + i);
      if(!IsUsable(close) || !IsUsable(atr) || MathAbs(close) <= HMMGRU_EPS)
         continue;
      mean += atr / MathAbs(close);
      valid++;
     }
   if(valid < 5)
      return(EMPTY_VALUE);
   mean /= valid;
   double variance = 0.0;
   for(int i = 0; i < lookback; i++)
     {
      double close = Close(index + i);
      double atr  = ATR(index + i);
      if(!IsUsable(close) || !IsUsable(atr) || MathAbs(close) <= HMMGRU_EPS)
         continue;
      double value = atr / MathAbs(close);
      variance += (value - mean) * (value - mean);
     }
   variance /= valid;
   double deviation = MathSqrt(MathMax(variance, HMMGRU_EPS));
   double current = ATR(index) / MathAbs(Close(index));
   return(Clamp((current - mean) / deviation, -4.0, 4.0));
  }

//+------------------------------------------------------------------+
//| Simple close-price mean.                                         |
//+------------------------------------------------------------------+
double CSignalHMMGRU::MeanClose(const int index, const int lookback)
  {
   if(lookback < 1 || !DataReady(index, lookback))
      return(EMPTY_VALUE);
   double total = 0.0;
   for(int i = 0; i < lookback; i++)
     {
      double value = Close(index + i);
      if(!IsUsable(value))
         return(EMPTY_VALUE);
      total += value;
     }
   return(total / lookback);
  }

//+------------------------------------------------------------------+
//| Highest high before the analyzed bar.                            |
//+------------------------------------------------------------------+
double CSignalHMMGRU::HighestPriorHigh(const int index, const int lookback)
  {
   if(lookback < 1 || !DataReady(index + 1, lookback))
      return(EMPTY_VALUE);
   double result = High(index + 1);
   for(int i = 2; i <= lookback; i++)
      if(High(index + i) > result)
         result = High(index + i);
   return(result);
  }

//+------------------------------------------------------------------+
//| Lowest low before the analyzed bar.                              |
//+------------------------------------------------------------------+
double CSignalHMMGRU::LowestPriorLow(const int index, const int lookback)
  {
   if(lookback < 1 || !DataReady(index + 1, lookback))
      return(EMPTY_VALUE);
   double result = Low(index + 1);
   for(int i = 2; i <= lookback; i++)
      if(Low(index + i) < result)
         result = Low(index + i);
   return(result);
  }

Initialization of indicators starts by letting 'CExpertSignal' initialize its inherited resources before working out the required buffered size. The GRU version requests more history given that it should build training targets while repeatedly searching further back for HMM and price windows. 'ResizePriceSeries()' does increase every shared buffer of price, while 'InitADX()' and 'InitATR()' add indicators to the framework's collection. These initializers also resize their buffers to have similar size. If there is any failed pre-allocation of memory or indicator creation then the initialization would terminate instead of allowing a partly setup model to trade.

'DataReady()' function verifies array boundaries and sees to it that key price and indicator values are numerical. The smaller helpers impose regular numerical behaviors of: clamping bound values ('Clamp()'); converting scores into a particular direction ('SignOf()'); selecting the vote with greater absolute magnitude ('Stronger()'). The function 'DirectionalIndex()' helps convert '+DI' and '-DI' into a bounded directional imbalance. This is given by the equation:

f11

The function 'ATRZScore()' initially divides the ATR by absolute price before standardizing the got percentage over a span of the current and old price bars. Got values are clipped to the range ([-4,4]). The 'MeanClose()' function supports range reversion, while the implementation of 'HighestPriorHigh()' and 'LowestPriorLow()' sets the starting index at '+1' so as to exclude the analyzed candle from its own breakout boundary. This indexing approach is vital. Within MQL5 time series, a larger index often means an older bar. Because of this, loops that cycle from 'index' towards 'index + lookback' travel back in known price history. The handy function 'StartIndex()' is inherited from 'CExpertBase', and it returns 1 when calculations are restrained to only completed bars and 0 when every-tick processing is enabled, in the Expert Advisor. In this last setup, our signal would respond to a still forming candle, and even though this is not look-ahead bias, it can make forecasts less stable.

Fitting and naming the HMM

//+------------------------------------------------------------------+
//| Diagonal two-dimensional Gaussian density.                       |
//+------------------------------------------------------------------+
double CSignalHMMGRU::GaussianEmission(const double x0, const double x1,
                                       const double mean0, const double mean1,
                                       const double variance0,
                                       const double variance1)
  {
   double var0 = MathMax(variance0, 0.0025);
   double var1 = MathMax(variance1, 0.0500);
   double d0 = x0 - mean0;
   double d1 = x1 - mean1;
   double exponent = -0.5 * (d0 * d0 / var0 + d1 * d1 / var1);
   exponent = MathMax(exponent, -60.0);
   double denominator = 2.0 * HMMGRU_PI * MathSqrt(var0 * var1);
   return(MathMax(MathExp(exponent) / denominator, 1.0e-300));
  }

//+------------------------------------------------------------------+
//| Fit a three-state HMM and return the current semantic regime.    |
//|                                                                  |
//| Observations run from oldest to newest. Feature 0 is ADX/100.    |
//| Feature 1 is the rolling z-score of ATR as a percentage of price.|
//| Baum-Welch uses scaled forward/backward probabilities. After     |
//| fitting, raw states are relabeled by their emission means so the |
//| labels remain range, trend, and volatile despite label switching.|
//+------------------------------------------------------------------+
bool CSignalHMMGRU::EstimateRegime(const int offset, int &regime,
                                   double &probability, double &posterior[])
  {
   if(ArraySize(posterior) < HMMGRU_STATES)
      return(false);
   const int total = m_hmm_window;
   if(!DataReady(offset, total))
      return(false);
   double obs_adx[];
   double obs_vol_raw[];
   double obs_vol[];
   ArrayResize(obs_adx, total);
   ArrayResize(obs_vol_raw, total);
   ArrayResize(obs_vol, total);
   double vol_mean = 0.0;
   for(int t = 0; t < total; t++)
     {
      int index = offset + (total - 1 - t);
      double close = Close(index);
      double adx  = ADX(index);
      double atr  = ATR(index);
      if(!IsUsable(close) || !IsUsable(adx) || !IsUsable(atr) ||
         MathAbs(close) <= HMMGRU_EPS)
         return(false);
      obs_adx[t] = Clamp(adx / 100.0, 0.0, 1.0);
      obs_vol_raw[t] = atr / MathAbs(close);
      vol_mean += obs_vol_raw[t];
     }
   vol_mean /= total;
   double vol_variance = 0.0;
   for(int t = 0; t < total; t++)
      vol_variance += (obs_vol_raw[t] - vol_mean) * (obs_vol_raw[t] - vol_mean);
   vol_variance /= total;
   double vol_deviation = MathSqrt(MathMax(vol_variance, HMMGRU_EPS));
   for(int t = 0; t < total; t++)
      obs_vol[t] = Clamp((obs_vol_raw[t] - vol_mean) / vol_deviation, -4.0, 4.0);
//--- persistent-transition initialization
   double prior[HMMGRU_STATES];
   double transition[HMMGRU_STATES][HMMGRU_STATES];
   double mean[HMMGRU_STATES][HMMGRU_OBS];
   double variance[HMMGRU_STATES][HMMGRU_OBS];
   prior[0] = 0.40;
   prior[1] = 0.35;
   prior[2] = 0.25;
   for(int i = 0; i < HMMGRU_STATES; i++)
      for(int j = 0; j < HMMGRU_STATES; j++)
         transition[i][j] = (i == j ? 0.90 : 0.05);
   mean[0][0] = 0.18;
   mean[0][1] = -0.35;
   mean[1][0] = 0.42;
   mean[1][1] = 0.00;
   mean[2][0] = 0.30;
   mean[2][1] = 1.00;
   variance[0][0] = 0.018;
   variance[0][1] = 0.40;
   variance[1][0] = 0.025;
   variance[1][1] = 0.55;
   variance[2][0] = 0.040;
   variance[2][1] = 0.75;
   double emission[][HMMGRU_STATES];
   double alpha[][HMMGRU_STATES];
   double beta[][HMMGRU_STATES];
   double gamma[][HMMGRU_STATES];
   double scale[];
   ArrayResize(emission, total);
   ArrayResize(alpha, total);
   ArrayResize(beta, total);
   ArrayResize(gamma, total);
   ArrayResize(scale, total);
//--- an extra final pass evaluates posteriors after the last update
   for(int iteration = 0; iteration <= m_hmm_iterations; iteration++)
     {
      for(int t = 0; t < total; t++)
         for(int state = 0; state < HMMGRU_STATES; state++)
            emission[t][state] = GaussianEmission(obs_adx[t], obs_vol[t],
                                                  mean[state][0], mean[state][1],
                                                  variance[state][0],
                                                  variance[state][1]);
      //--- scaled forward pass
      scale[0] = 0.0;
      for(int state = 0; state < HMMGRU_STATES; state++)
        {
         alpha[0][state] = prior[state] * emission[0][state];
         scale[0] += alpha[0][state];
        }
      if(scale[0] <= HMMGRU_EPS)
         return(false);
      for(int state = 0; state < HMMGRU_STATES; state++)
         alpha[0][state] /= scale[0];
      for(int t = 1; t < total; t++)
        {
         scale[t] = 0.0;
         for(int state = 0; state < HMMGRU_STATES; state++)
           {
            double incoming = 0.0;
            for(int previous = 0; previous < HMMGRU_STATES; previous++)
               incoming += alpha[t - 1][previous] * transition[previous][state];
            alpha[t][state] = incoming * emission[t][state];
            scale[t] += alpha[t][state];
           }
         if(scale[t] <= HMMGRU_EPS)
            return(false);
         for(int state = 0; state < HMMGRU_STATES; state++)
            alpha[t][state] /= scale[t];
        }
      //--- scaled backward pass
      for(int state = 0; state < HMMGRU_STATES; state++)
         beta[total - 1][state] = 1.0;
      for(int t = total - 2; t >= 0; t--)
        {
         for(int state = 0; state < HMMGRU_STATES; state++)
           {
            double outgoing = 0.0;
            for(int next = 0; next < HMMGRU_STATES; next++)
               outgoing += transition[state][next] * emission[t + 1][next] *
                           beta[t + 1][next];
            beta[t][state] = outgoing / MathMax(scale[t + 1], HMMGRU_EPS);
           }
        }
      //--- smoothed state probabilities
      for(int t = 0; t < total; t++)
        {
         double denominator = 0.0;
         for(int state = 0; state < HMMGRU_STATES; state++)
           {
            gamma[t][state] = alpha[t][state] * beta[t][state];
            denominator += gamma[t][state];
           }
         if(denominator <= HMMGRU_EPS)
            return(false);
         for(int state = 0; state < HMMGRU_STATES; state++)
            gamma[t][state] /= denominator;
        }
      if(iteration == m_hmm_iterations)
         break;
      //--- transition sufficient statistics
      double xi_sum[HMMGRU_STATES][HMMGRU_STATES];
      double gamma_transition_sum[HMMGRU_STATES];
      for(int i = 0; i < HMMGRU_STATES; i++)
        {
         gamma_transition_sum[i] = 0.0;
         for(int j = 0; j < HMMGRU_STATES; j++)
            xi_sum[i][j] = 0.0;
        }
      for(int t = 0; t < total - 1; t++)
        {
         double denominator = 0.0;
         for(int i = 0; i < HMMGRU_STATES; i++)
            for(int j = 0; j < HMMGRU_STATES; j++)
               denominator += alpha[t][i] * transition[i][j] *
                              emission[t + 1][j] * beta[t + 1][j];
         if(denominator <= HMMGRU_EPS)
            continue;
         for(int i = 0; i < HMMGRU_STATES; i++)
           {
            gamma_transition_sum[i] += gamma[t][i];
            for(int j = 0; j < HMMGRU_STATES; j++)
               xi_sum[i][j] += alpha[t][i] * transition[i][j] *
                               emission[t + 1][j] * beta[t + 1][j] / denominator;
           }
        }
      //--- initial probabilities and transition matrix
      double prior_total = 0.0;
      for(int state = 0; state < HMMGRU_STATES; state++)
        {
         prior[state] = MathMax(gamma[0][state], 0.001);
         prior_total += prior[state];
        }
      for(int state = 0; state < HMMGRU_STATES; state++)
         prior[state] /= prior_total;
      for(int i = 0; i < HMMGRU_STATES; i++)
        {
         double row_total = 0.0;
         for(int j = 0; j < HMMGRU_STATES; j++)
           {
            double estimate = transition[i][j];
            if(gamma_transition_sum[i] > HMMGRU_EPS)
               estimate = xi_sum[i][j] / gamma_transition_sum[i];
            //--- a small persistence prior prevents single-bar state flicker
            double persistent = (i == j ? 0.90 : 0.05);
            transition[i][j] = MathMax(0.98 * estimate + 0.02 * persistent, 0.001);
            row_total += transition[i][j];
           }
         for(int j = 0; j < HMMGRU_STATES; j++)
            transition[i][j] /= row_total;
        }
      //--- Gaussian means
      double new_mean[HMMGRU_STATES][HMMGRU_OBS];
      double state_weight[HMMGRU_STATES];
      for(int state = 0; state < HMMGRU_STATES; state++)
        {
         state_weight[state] = 0.0;
         new_mean[state][0] = 0.0;
         new_mean[state][1] = 0.0;
         for(int t = 0; t < total; t++)
           {
            state_weight[state] += gamma[t][state];
            new_mean[state][0] += gamma[t][state] * obs_adx[t];
            new_mean[state][1] += gamma[t][state] * obs_vol[t];
           }
         if(state_weight[state] > HMMGRU_EPS)
           {
            new_mean[state][0] /= state_weight[state];
            new_mean[state][1] /= state_weight[state];
           }
         else
           {
            new_mean[state][0] = mean[state][0];
            new_mean[state][1] = mean[state][1];
           }
        }
      //--- diagonal Gaussian variances
      for(int state = 0; state < HMMGRU_STATES; state++)
        {
         double new_variance0 = 0.0;
         double new_variance1 = 0.0;
         for(int t = 0; t < total; t++)
           {
            double d0 = obs_adx[t] - new_mean[state][0];
            double d1 = obs_vol[t] - new_mean[state][1];
            new_variance0 += gamma[t][state] * d0 * d0;
            new_variance1 += gamma[t][state] * d1 * d1;
           }
         if(state_weight[state] > HMMGRU_EPS)
           {
            new_variance0 /= state_weight[state];
            new_variance1 /= state_weight[state];
           }
         variance[state][0] = MathMax(new_variance0, 0.0025);
         variance[state][1] = MathMax(new_variance1, 0.0500);
         mean[state][0] = new_mean[state][0];
         mean[state][1] = new_mean[state][1];
        }
     }
//--- semantic relabeling: highest volatility first, then highest ADX
   int volatile_raw = 0;
   for(int state = 1; state < HMMGRU_STATES; state++)
      if(mean[state][1] > mean[volatile_raw][1])
         volatile_raw = state;
   int trend_raw = -1;
   for(int state = 0; state < HMMGRU_STATES; state++)
     {
      if(state == volatile_raw)
         continue;
      if(trend_raw < 0 || mean[state][0] > mean[trend_raw][0])
         trend_raw = state;
     }
   int range_raw = 0;
   for(int state = 0; state < HMMGRU_STATES; state++)
      if(state != volatile_raw && state != trend_raw)
        {
         range_raw = state;
         break;
        }
   posterior[HMMGRU_REGIME_RANGE]   = gamma[total - 1][range_raw];
   posterior[HMMGRU_REGIME_TREND]   = gamma[total - 1][trend_raw];
   posterior[HMMGRU_REGIME_VOLATILE] = gamma[total - 1][volatile_raw];
   double posterior_total = posterior[0] + posterior[1] + posterior[2];
   if(posterior_total <= HMMGRU_EPS)
      return(false);
   for(int state = 0; state < HMMGRU_STATES; state++)
      posterior[state] /= posterior_total;
   regime = 0;
   probability = posterior[0];
   for(int state = 1; state < HMMGRU_STATES; state++)
      if(posterior[state] > probability)
        {
         regime = state;
         probability = posterior[state];
        }
   return(true);
  }

The 'GaussianEmission()' function determines the probability of two observations under a diagonal Gaussian state. We apply floors to the variance to prevent a state from collapsing around a very small sample of observations. Concurrently we limit the exponent to be no less than -60 to reduce underflow. Our diagonal assumption keeps computation small but this excludes direct within-state covariance of the ADX and volatility. 'EstimateRegime()' starts with an 'offset', the bar whose regime is needed. The expression below:

int index = offset + (total - 1 - t);

helps reverse MQL5's series indexing so that the HMM observations are kept in the order oldest to newest. ADX is divided by 100, the ATR also gets divided by absolute closing price in order to standardize the data over the fitting window. Every observation thus uses data that ends at its own requested offset. The starting state probabilities favor range slightly, and the transition matrix starts on its diagonal with 0.9 while 0.05 is elsewhere. These beginning values show the reasonable assumption that regimes can persist. Initial emission averages similarly imply low-ADX/low-volatility, higher-ADX trend, and high-volatility states.

These parameters are local to the 'EstimateRegime()' function where they get initialized and refitted afresh with every call instead of being carried forward as a persistent HMM. This makes the historical regime able to reproduce and also avoids later parameter estimates from leaking backward. Nonetheless it is compute intense. The Baum-Welch loop begins by calculating emission probabilities. Its forward pass brings together the likelihood of getting to each state with the current emission and it normalizes every time step using 'scale[t]'. The backward pass uses the same scaling factors but in reverse. Multiplying the two passes but normalizing gives us 'gamma', the posterior state probabilities.

Without the cycle loop getting to its last inference pass, we continue the expected state transitions, update the initial probabilities as well as transition matrix. In addition we have to re-estimate the Gaussian averages and variances. We use a two-percent pull towards the original price levels as a deterrent to single-bar switching. The loop runs one final forward-backward pass after the last parameter updates so that the returned posterior gets to correspond to the last fitted parameters. Raw HMM labels do not get permanent meanings. For instance state-0 could be a trend cluster in one fit while being a volatility cluster in another. We thus have to re-label them following each fit. The raw state that has the highest volatility  is named 'VOLATILE'; and from the remaining states, the one with the highest mean becomes 'TREND'; while the last is labelled 'RANGE'. 'RegimeGate()' changes HMM classification into a trading filter. This filter rejects setups with expected regimes that are below or absent the minimum probability. Votes that get accepted have a gate strength of 0.75-1.0.

To wrap up this code description, our custom class has seven modes comprising: trend continuation, DI crossover, range reversion, volatile breakout, post-compression expansion, regime dependent patterns of candles, and adaptive selection of stronger predefined candidate. Scoring is ranged from -100 to +100 with sign indicating direction and level of confidence. 

Three regime-particular six-unit GRUs employ normalized ADX, DI imbalance, ATR z-score, one-bar price movement, as well as the body size of a candle. The inputs are range bound to [-1.0, +1.0], and the only thing that persists between predictions are the trained weights. Training utilizes completed bars and the targets of the next-bar. It also uses one-step truncated backpropagation, gradient clipping, as well as weight decay The HMM gives every sample to a GRU, however the 20-sample minimum is used globally so that regime networks can get limited training.

In forecasting, the active GRU looks at recent consecutive bars that are likely in the same regime. The last signal survives only when the algorithm is not zero, and GRU confidence is better than the threshold with both directions agreeing. This GRU can veto, preserve or even strengthen a vote, however it cannot gradually weaken it. The results get cached and forwarded through CExpertSignal, CExpert, and CExpertTrade classes for order execution. If we disable GRU while maintaining other settings fixed, this can give us the baseline for tracking the network's real contribution.


Post-Optimization Forward-Testing

We performed post optimization tests without the GRU and with the GRU. Both MetaTrader reports are presented below:

r1

c1

r2

c2

From our two reports above, the forward walks were set up differently. Both configurations used the adaptive algorithm on the 4-hour timeframe with Mode-6, but the GRU version used different entry, exit, indicator, and HMM settings. As a result, their results could describe two independently tuned systems; however, we cannot isolate the incremental effect of the GRU.

Both tests traded EURUSD with a starting margin of 10000, at 1:100 leverage, and reported 100% history price quality. That notwithstanding, their remaining differences could change the number, timing, and payoff structure independently of the GRU. Comparing the forward-test results from the optimized runs presents us with the following table:

The GRU system produced the higher absolute return, with net profit of U$3,142.03 against U$1,085.99 without GRU. Its expected payoff was also stronger at U$314.20 versus U$120.67, while its profit factor stood at 1.98. The non-GRU report displays a profit factor of 0.00 only because it registered no gross loss. It won all 9 trades, whereas the GRU configuration won 7 of 10.

The stronger GRU return did not result in better risk-adjusted performance. Its recovery factor was 1.25 and its Sharpe ratio was 0.78, compared with 2.45 and 1.98 for the non-GRU run. Maximum equity drawdown was also higher at 16.12% versus 4.19%. The GRU mean winner bagged U$907.37 against an average loss of -U$1,069.86, so one typical loss consumed about 1.18 average winners. The non-GRU run averaged U$120.67 per winner and recorded no losses. Therefore, the GRU delivered about 2.9 times more net profit and 2.6 times more profit per trade, while the algorithm-only version was steadier and more capital-efficient. With only 10 and 9 trades respectively, both samples are too small for a firm verdict, and the unequal settings prevent us from crediting the difference to the GRU alone.

What these reports establish—and what they do not

These results make the case that both the chosen configurations can be profitable in their respective forward tests. They also show that the GRU system's apparent strength is based in its selectivity and hit-rate, while the main weakness could be the severe negative payoff asymmetry. These reports do not show that enabling the GRU improves the same strategy. Comparing these headline figures could confound the network timeframe, signal-mode, HMM window, thresholds, as well as trade management settings. Thus choosing either architecture as superior based on these four reports, though tidy, would be statistically unsound.

EURUSD FORWARD TEST - PERFORMANCE DIAGNOSTICS

sum_1

sum_2

The missing experiment remains as a pairing with the 4-hour timeframe in Mode-0 run with the GRU and without the GRU. This would involve logging every candidate before and after the neural confirmation and it hopefully would reveal if the GRU vetoed losing trades, rejected profitable ones or just lessened trading activity. This is the test that is pitched above in the intro and is left to the reader to investigate given that all source code is attached at the end of this article. Unless this control is available, the forward performance supports only testing HMM-GRU architecture, but it makes no case that the GRU is a step-up.


Conclusion

Our HMM-GRU model is logically coherent, since the HMM identifies the market environment, the chosen algorithm mode proposes a trade, and the regime-specific GRU confirms or vetoes it. The whole hypothesis can be put in place, inspected and ablated within MQL5. This is a practical advantage for reproducible Expert Advisor development. Nevertheless, the evidence presented in this article has not established that the GRU improves entry selection. Both tested options were profitable in the forward periods; however, the non-GRU system gave a higher net-profit, expectancy, profit-factor, Sharpe-ratio, and recovery factor. The GRU system for its part brought in a higher win-rate by pairing frequent small winners with occasional 5x losses.

These results concede to two possible interpretations. Regime-conditional memory could be helping the GRU reject entries that are weak and get high accuracy. On the other hand, its performance could be a reflection of a favorable trend, a too small sample, different strategy settings, or negatively skewed exits instead of the useful neural confirmation. Given that the GRU and non-GRU use separate timeframes, signal modes, thresholds, and trade management, neither interpretation is preferred per se. These forward results do argue for further testing, not a victory lap. The immediate next step therefore would be simple: rerun the GRU and non-GRU options in similar timeframes and signal modes and compare admitted and vetoed candidates across a rolling window. Until one has this baseline, adding another layer of cleverness would only make the unanswered question more expensive.


Extra Tests

Also attached is another test run with EURUSD on the 1-hour time frame with the forward walk covering the 8 months so far of 2026. 

f3

c

namedescription
03.mq5Wizard Assembled Expert Advisor whose header lists reference code files
SignalHMMGRU.mqhCustom Signal Class required by the MQL5 Wizard in assembling an Expert Advisor.

Attached files |
03.mq5 (9.4 KB)
SignalHMMGRU.mqh (71.39 KB)
Walsh Functions in Modern Trading Walsh Functions in Modern Trading
The article discusses the application of Walsh functions in trading. We will explore the basic principles of using these functions to analyze financial markets, forecast prices, and make trading decisions. We will also discuss the advantages and disadvantages of these functions, as well as the prospects for their application in trading and technical analysis.
Partial Information Decomposition: When Two Indicators Together Say More Than Either Alone Partial Information Decomposition: When Two Indicators Together Say More Than Either Alone
We introduce a Partial Information Decomposition library for MQL5 that decomposes two sources about a target into four atoms: unique to each, shared, and synergy. The implementation uses quantile binning, tabulated logarithms, and a maximum-entropy fit (for I_ccs), and it pairs results with a block-permutation null because atoms sit above zero on finite samples. Use it to screen indicator pairs and judge significance, including family-wise correction.
Features of Experts Advisors Features of Experts Advisors
Creation of expert advisors in the MetaTrader trading system has a number of features.
Neural Networks in Trading: Disentangling Structured Components (Conclusion) Neural Networks in Trading: Disentangling Structured Components (Conclusion)
The article provides a detailed explanation of the SCNN architecture and one way to implement it using MQL5. We will show how time series decomposition can be combined with neural network methods and attention mechanisms.