Русский Español Português
preview
Neural Networks in Trading: Disentangling Structured Components (Conclusion)

Neural Networks in Trading: Disentangling Structured Components (Conclusion)

MetaTrader 5Trading systems |
201 0
Dmitriy Gizlyk
Dmitriy Gizlyk

Introduction

In this article, we move on to the final stage of implementing our own vision of the approaches proposed by the authors of the SCNN framework using MQL5. SCNN (Seasonal Convolutional Neural Network) is a specialized architecture designed to analyze time series with a distinct structure. Its main objective is to decompose the raw data into several key components — the long-term trend, the seasonal component, and short-term components — while also accounting for the spatial relationships between variables. This decomposition not only improves forecast quality but also ensures that the model is interpretable — a rare but extremely valuable quality in algorithmic trading tasks.

SCNN is based on classical principles of analysis, including normalization by period and working with seasonal transformation, but combines them with modern information processing methods: an attention mechanism, parametric feature projection, and convolutional aggregation of results. The authors' visualization of the SCNN framework is shown below.

In our previous work, we systematically examined the structure and purpose of all the model's internal components, implementing them through specialized classes and carefully building each element of the future architecture. The work turned out to be challenging, but the result was well worth the effort: we now have a set of fully-fledged modules, each of which plays a strictly defined role in the system.

The next logical step is to combine them into a single, coherent structure. It is at this stage that the architectural framework of SCNN is formed, determining exactly how information flows will be organized within the model — from the input data to the final forecast. We are moving from the preparation phase to the operational phase: the components come to life, interact, and ultimately form a single analytical mechanism.

Now that we have completed the technical assembly, let's move on to the most anticipated part — testing the model on historical data. This will make it possible to evaluate not only the correctness of the implementation but also the practical robustness of the approach under various market regimes. SCNN is not just another neural network architecture. This is an attempt to combine computational accuracy with transparency in decision-making.



SCNN Encoder

In the previous article, we concluded our analysis of the architecture of the SCNN Encoder, implemented in the CNeuronSCNNEncoder object, and examined in detail the initialization procedure for all of its internal components. Each module — whether normalization, transposition, or spatial adaptation — was prepared for operation and configured to receive and process the input data. The structure of the object is shown below.

class CNeuronSCNNEncoder   :  public CNeuronTransposeOCL
  {
protected:
   CNeuronPeriodNorm       cLongNorm;
   CNeuronTransposeVRCOCL  cSeasonTransp;
   CNeuronPeriodNorm       cSeasonNorm;
   CNeuronTransposeVRCOCL  cUnSeasonTransp;
   CNeuronPeriodNorm       cShortNorm;
   CNeuronAdaptSpatialNorm cAdaptSpatNorm;
   CNeuronBaseOCL          cConcatenated;
   CNeuronSwiGLUOCL        cProjection;
   CNeuronTransposeOCL     cTranspose;
   CNeuronConvOCL          caFusion[2];
   CNeuronBaseOCL          cFusionOut;
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL)  override;
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL)  override;

public:
                     CNeuronSCNNEncoder(void) {};
                    ~CNeuronSCNNEncoder(void) {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                          uint units_count, uint variables, uint forecast,
                          uint season_period, uint short_period,
                          ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual bool      Save(const int file_handle) override;
   virtual bool      Load(const int file_handle) override;
   //---
   virtual int       Type(void) override const  {  return defNeuronSCNNEncoder; }
   virtual void      TrainMode(bool flag) override;
   virtual void      SetOpenCL(COpenCLMy *obj) override;
   //---
   virtual bool      WeightsUpdate(CNeuronBaseOCL *source, float tau) override;
   virtual void      SetActivationFunction(ENUM_ACTIVATION value) override { }
  };

Today, we're continuing the work we started and moving on to building a key component — the forward pass algorithm. This is what determines how the data sequentially pass through all layers of the model, are transformed and aggregated, and ultimately form the output representation.

The implementation of the forward pass in the feedForward method demonstrates the coordinated operation of the entire system.

bool CNeuronSCNNEncoder::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   if(!cLongNorm.FeedForward(NeuronOCL))
      return false;

The input data first pass through a long-term normalization block, where bias and scale differences accumulated over a long period are removed. This creates an aligned foundation for further analysis.

The processed data are then passed along the chain to the seasonal component extraction block. Here, as we discussed in the previous article, transposition is performed with a specified step size corresponding to the seasonal period. As a result, the elements of the time series are grouped into sequences by cycle phase, allowing stable seasonal patterns to be identified more effectively. The resulting structure is normalized by period, which enhances the expressiveness of seasonal fluctuations. Inverse transposition then prepares the data for subsequent processing steps.

   if(!cSeasonTransp.FeedForward(cLongNorm.AsObject()))
      return false;
   if(!cSeasonNorm.FeedForward(cSeasonTransp.AsObject()))
      return false;
   if(!cUnSeasonTransp.FeedForward(cSeasonNorm.AsObject()))
      return false;

After seasonal processing comes the short-term component extraction stage. In this step, we essentially extract local, rapid fluctuations that may contain important information about recent changes in market dynamics.

   if(!cShortNorm.FeedForward(cUnSeasonTransp.AsObject()))
      return false;

The final step in the preprocessing phase is the application of spatial normalization. This stage is critical for aligning the information extracted from the different components — long-term, seasonal, and short-term. Spatial normalization makes it possible to adjust the scale and relative influence of the input variables, eliminating distortions that arise from differences in dynamics between features. The key feature of this step is that it is implemented with spatial dependencies in mind and uses an attention mechanism, making the resulting representation particularly expressive and informative.

   if(!cAdaptSpatNorm.FeedForward(cShortNorm.AsObject()))
      return false;

Before moving on to the next stage, it is necessary to combine the results from all modules — including the normalized data and computed statistical parameters — into a single coherent tensor. This is a critically important step that ensures the subsequent integrity of the information flow within the model.

It is worth emphasizing that, although the dimensionality of the time series themselves is preserved after normalization, we deliberately chose to avoid stretching the statistical parameters (means and standard deviations) to the full length of the series. This decision was made to conserve memory — after all, repeating the same value throughout the entire length does not convey any additional information. However, this optimization results in a dimensionality mismatch between the time series and the corresponding statistics.

Despite this, we still retain a common grid in terms of the number of univariate sequences analyzed — and that is the key point. That is exactly what we rely on during concatenation. All operations are performed sequentially across these univariate segments, which ensures the correctness of the final assembly and prevents distortions in the data structure.

At the very beginning of the merging stage, we form the first concatenated block, which includes the raw input data and the output of the long-term normalization module. This makes it possible to preserve the context of the original series while supplementing it with information about long-term trends identified during normalization. In addition, we add the computed statistical parameters (mean values and standard deviations) to these data, which serve as markers of scale and variability.

   uint windows[3] = {NeuronOCL.Neurons() / iWindow,
                      cLongNorm.GetPeriod()*cLongNorm.GetUnits(),
                      2 * cLongNorm.GetUnits()
                     };
   if(!Concat(NeuronOCL.getOutput(), cLongNorm.getOutput(), cLongNorm.GetMeanSTDevs().getOutput(),
              cConcatenated.getOutput(), windows[0], windows[1], windows[2], iWindow))
      return false;

In the next step, we expand our tensor using information extracted from the seasonal component.

   windows[0] = windows[0] + windows[1] + windows[2];
   windows[1] = cSeasonNorm.GetPeriod() * cSeasonNorm.GetUnits();
   windows[2] = 2 * cSeasonNorm.GetUnits();
   if(!cConcatenated.SwapOutputs() ||
      !Concat(cConcatenated.getPrevOutput(), cSeasonNorm.getOutput(),
              cSeasonNorm.GetMeanSTDevs().getOutput(), cConcatenated.getOutput(),
              windows[0], windows[1], windows[2], iWindow))
      return false;

After the seasonal information, data from the short-term and spatial components are added to the tensor in turn. At this stage, we continue to follow the logic of combining along univariate sequences, sequentially expanding the tensor with new features.

   windows[0] = windows[0] + windows[1] + windows[2];
   windows[1] = cShortNorm.GetPeriod() * cShortNorm.GetUnits();
   windows[2] = 2 * cShortNorm.GetUnits();
   if(!cConcatenated.SwapOutputs() ||
      !Concat(cConcatenated.getPrevOutput(), cShortNorm.getOutput(),
              cShortNorm.GetMeanSTDevs().getOutput(), cConcatenated.getOutput(),
              windows[0], windows[1], windows[2], iWindow))
      return false;
   windows[0] = windows[0] + windows[1] + windows[2];
   windows[1] = cAdaptSpatNorm.GetUnits();
   windows[2] = 2 * cAdaptSpatNorm.GetUnits();
   if(!cConcatenated.SwapOutputs() ||
      !Concat(cConcatenated.getPrevOutput(), cAdaptSpatNorm.getOutput(),
              cAdaptSpatNorm.GetMeanSTDevs().getOutput(), cConcatenated.getOutput(),
              windows[0], windows[1], windows[2], iWindow))
      return false;

The short-term component provides the model with up-to-date context — local fluctuations, spikes, and micro-patterns — which are particularly important for high forecast sensitivity. Spatial normalization, in turn, completes the preparation phase by providing additional stability and smoothing the raw data across all variables.

As a result, we create a meaningful and balanced representation of the time series, in which each component is presented in a consistent form. This combined tensor is passed to the projection layer, whose task is to generate a weighted representation of the input data, taking into account the forecast horizon. At this stage, the data are adjusted to the required output dimensionality, adapting them to future analysis tasks. It is precisely here that the foundation is laid for generating a meaningful forecast capable of taking into account both historical depth and the structure of expected changes.

   if(!cProjection.FeedForward(cConcatenated.AsObject()))
      return false;

The representations of individual univariate sequences obtained in the previous stages are aligned into the structure of a multimodal time series in the Fusion module. Here, the representation shifts from univariate sequences to time steps: the tensor is transposed, and further processing is then built along the time axis.

At this stage, two consecutive convolutional layers are used. The first of these uses the TANH activation function, making it possible to learn a rich nonlinear representation of each feature — in essence, a normalized form of the feature signal. The second layer, which uses SIGMOID, acts as a gate: it determines the importance of each feature within the temporal context by softly weighting the obtained values.

The final result is obtained by element-wise multiplication of the outputs of the two layers, making it possible to effectively filter out noise and highlight the most significant characteristics of the time series — already in the aligned space of the forecasting task.

   if(!cTranspose.FeedForward(cProjection.AsObject()))
      return false;
   for(uint i = 0; i < caFusion.Size(); i++)
      if(!caFusion[i].FeedForward(cTranspose.AsObject()))
         return false;
   if(!ElementMult(caFusion[0].getOutput(), caFusion[1].getOutput(), cFusionOut.getOutput()))
      return false;
//---
   return CNeuronTransposeOCL::feedForward(cFusionOut.AsObject());
  }

In the final stage of the forward pass, the data undergo inverse transposition, returning from the temporal representation to the original structure of univariate sequences. This ensures consistency between the input and output formats of the Encoder.

Thus, the feedForward method is a carefully organized and well-thought-out architecture in which each component performs a strictly defined function. From normalization and decomposition to projections and multimodal aggregation—the entire process is aimed at generating a rich, structured, and informative description of the time series.

Now that we have finished analyzing the forward pass algorithm, it makes sense to move on to an equally important step — backpropagation. It is at this very moment that the model begins to learn: the gradient values calculated at the output are gradually fed back through the entire chain of operations, allowing the internal parameters of each module to be adjusted. The calcInputGradients method implements this process, ensuring strict sequencing and consistency with the forward pass architecture.

bool CNeuronSCNNEncoder::calcInputGradients(CNeuronBaseOCL *NeuronOCL)
  {
   if(!NeuronOCL)
      return false;
//---
   if(!CNeuronTransposeOCL::calcInputGradients(cFusionOut.AsObject()))
      return false;
   if(!ElementMultGrad(caFusion[0].getOutput(), caFusion[0].getGradient(),
                       caFusion[1].getOutput(), caFusion[1].getGradient(),
                       cFusionOut.getGradient(), caFusion[0].Activation(), caFusion[1].Activation()))
      return false;
   if(!cTranspose.CalcHiddenGradients(caFusion[0].AsObject()))
      return false;

The backward pass begins at the top level of the model, where the gradients obtained from the next layer of the model (or from the loss function, if this is the output itself) are used as input. The convolutional blocks used in the Fusion module are processed first. It is important to understand that, during the forward pass, we used two parallel convolutional channels: one with the TANH activation function to generate a nonlinear feature, and the other with SIGMOID, acting as a gate that determines the feature’s degree of importance. At the output, these channels were multiplied element-wise to form the final representation.

In the backward pass, this multiplication operation requires special handling. The ElementMultGrad method correctly splits the gradients back into both branches, taking into account the specific characteristics of each activation function. This is critically important for the accuracy of the calculations, since this stage determines how the convolution weights should be adjusted.

Next, we proceed to pass the gradients to the Transpose module, which, as a reminder, was responsible in the forward pass for reorienting the tensor axes — from the representation of univariate sequences to time steps. This was exactly the form in which the data were fed into both convolutional layers of the Fusion module; therefore, the task of the backward pass is to correctly combine the gradients coming from both branches.

The first step is to pass down the error gradient from one of the convolutional layers. Instead of copying the entire tensor's data, we substitute the pointer to the data buffer, which allows us to efficiently switch the memory region and store the resulting values without unnecessary resource overhead. Next, with the pointer substituted, we run the backward pass of the second convolutional layer.

   CBufferFloat* temp = cTranspose.getGradient();
   if(!cTranspose.SetGradient(cTranspose.getPrevOutput(), false) ||
      !cTranspose.CalcHiddenGradients(caFusion[1].AsObject()) ||
      !SumAndNormilize(temp, cTranspose.getGradient(), temp, cTranspose.GetCount(),
                                                              false, 0, 0, 0, 1) ||
      !cTranspose.SetGradient(temp, false))
      return false;

After that, the two gradient streams are accumulated through element-wise summation, reflecting the combined effect of both branches on the final error. Finally, the buffer pointers are returned to their original state, ensuring that the data structure remains correct for further gradient propagation. This approach ensures model consistency and efficient memory handling during training.

The error is then passed to the Projection block, which is responsible for weighting the information before the convolution modules. The gradient from it is passed down to the Concatenated block, where one of the most technically complex stages begins—backward deconcatenation.

   if(!cProjection.CalcHiddenGradients(cTranspose.AsObject()))
      return false;
   if(!cConcatenated.CalcHiddenGradients(cProjection.AsObject()))
      return false;

As a reminder, during the forward pass, we gradually combined data from various normalizers: long-term, seasonal, short-term, and spatial. Furthermore, along with the normalized data, statistical parameters such as means and standard deviations were also included in the combined tensor.

Now the task is the reverse — to unwrap the concatenated tensor back into its constituent parts. To do this, the sequential DeConcat procedure is used, in which the window sizes for each component are calculated in strict accordance with the previous layout. This step requires particular precision, as even the slightest error in the window size can result in data misalignment or loss.

   uint windows[3] = {0};
   windows[1] = cAdaptSpatNorm.GetUnits();
   windows[2] = 2 * cAdaptSpatNorm.GetUnits();
   windows[0] = cConcatenated.Neurons() / iWindow - windows[1] - windows[2];
   if(!DeConcat(cConcatenated.getPrevOutput(), cAdaptSpatNorm.getGradient(),
                cAdaptSpatNorm.GetMeanSTDevs().getGradient(),
                cConcatenated.getGradient(), windows[0], windows[1], windows[2], iWindow))
      return false;
   windows[1] = cShortNorm.GetPeriod() * cShortNorm.GetUnits();
   windows[2] = 2 * cShortNorm.GetUnits();
   windows[0] = windows[0] - windows[1] - windows[2];
   if(!DeConcat(cConcatenated.getGradient(), cShortNorm.getPrevOutput(),
                cShortNorm.GetMeanSTDevs().getGradient(),
                cConcatenated.getPrevOutput(), windows[0], windows[1], windows[2], iWindow))
      return false;
   windows[1] = cSeasonNorm.GetPeriod() * cSeasonNorm.GetUnits();
   windows[2] = 2 * cSeasonNorm.GetUnits();
   windows[0] = windows[0] - windows[1] - windows[2];
   if(!DeConcat(cConcatenated.getPrevOutput(), cSeasonNorm.getPrevOutput(),
                cSeasonNorm.GetMeanSTDevs().getGradient(),
                cConcatenated.getGradient(), windows[0], windows[1], windows[2], iWindow))
      return false;
   windows[1] = cLongNorm.GetPeriod() * cLongNorm.GetUnits();
   windows[2] = 2 * cLongNorm.GetUnits();
   windows[0] = windows[0] - windows[1] - windows[2];
   if(!DeConcat(NeuronOCL.getPrevOutput(), cLongNorm.getPrevOutput(),
                cLongNorm.GetMeanSTDevs().getGradient(),
                cConcatenated.getPrevOutput(), windows[0], windows[1], windows[2], iWindow))
      return false;

At this stage, it is worth highlighting an important technical point. All normalization modules, with the exception of spatial normalization, were not the final stage in the data preparation chain. Their results were used as input data for the subsequent processing stages. This means that at the time of the backward pass, each such module receives the error gradient not from a single source, but from several. To correctly account for the contribution of all subsequent operations, we cannot simply overwrite the gradient, as is customary in simpler architectures. Instead, a backup buffer is used: each module saves its current gradient there before receiving a new one.

Thus, the backward pass through the normalization chain is implemented with gradient accumulation in special buffers. This ensures that the influence of each model component is accurately recovered and prevents information loss.

After successful deconcatenation, each of the normalization modules — starting with the short-term cShortNorm, followed by the seasonal cSeasonNorm, and finally the long-term cLongNorm — receives the corresponding share of the error gradient, reflecting the influence of the data processing chain in the forward pass. But as we have already noted, each of these normalizers was involved in more than just the data preparation process. Therefore, simply passing the gradient back along a single path would not be enough.

In practice, this is implemented as follows. After the normalizer receives the error gradient from the object that uses its data, we add it to the values already obtained during the deconcatenation stage. Thus, at each stage, we carefully sum information from several sources. Only after this aggregation step is the resulting gradient passed down the chain to the previous layer.

   if(!cShortNorm.CalcHiddenGradients(cAdaptSpatNorm.AsObject()) ||
      !SumAndNormilize(cShortNorm.getGradient(), cShortNorm.getPrevOutput(),
                       cShortNorm.getGradient(), cShortNorm.GetPeriod(), false, 0, 0, 0, 1))
      return false;
   if(!cUnSeasonTransp.CalcHiddenGradients(cShortNorm.AsObject()))
      return false;
   if(!cSeasonNorm.CalcHiddenGradients(cUnSeasonTransp.AsObject()) ||
      !SumAndNormilize(cSeasonNorm.getGradient(), cSeasonNorm.getPrevOutput(),
                       cSeasonNorm.getGradient(), cSeasonNorm.GetPeriod(), false, 0, 0, 0, 1))
      return false;
   if(!cSeasonTransp.CalcHiddenGradients(cSeasonNorm.AsObject()))
      return false;
   if(!cLongNorm.CalcHiddenGradients(cSeasonTransp.AsObject()) ||
      !SumAndNormilize(cLongNorm.getGradient(), cLongNorm.getPrevOutput(),
                       cLongNorm.getGradient(), cLongNorm.GetPeriod(), false, 0, 0, 0, 1))
      return false;

This approach ensures that no information link established in the forward pass will be lost or distorted during backpropagation. It is precisely this rigor and care in transmitting gradients that is one of the factors contributing to the stability and high interpretability of the SCNN framework in real-market conditions.

The backward pass is completed by computing the gradients at the very input — the NeuronOCL object. This step is critically important, as this is where all the information streams — having passed through a complex chain of transformations and normalizations — converge.

   if(!NeuronOCL.CalcHiddenGradients(cLongNorm.AsObject()) ||
      !SumAndNormilize(NeuronOCL.getGradient(), NeuronOCL.getPrevOutput(),
                       NeuronOCL.getGradient(), cLongNorm.GetPeriod(), false, 0, 0, 0, 1))
      return false;
//---
   return true;
  }

It is important to note that two key streams converge at this point: one from a complex system of normalizations, and the other from the projection layer responsible for forming the feature space for forecasting. Their proper aggregation ensures the continuity of the gradient flow and the correct updating of the model weights during training.

Thus, the calcInputGradients method implements not merely a technical step backward along the chain, but a refined, modular, and structured backpropagation procedure. Every operation in it corresponds precisely to the previous steps of the forward pass, which makes the SCNN Encoder architecture coherent, symmetrical, and easy to interpret.

The optimization of the trainable parameters of the Encoder CNeuronSCNNEncoder is implemented by delegating responsibility to the corresponding internal modules, each of which contains its own weights and implements its own update logic. The updateInputWeights method sequentially delegates control to these modules, providing centralized yet modular management of the training process.

bool CNeuronSCNNEncoder::updateInputWeights(CNeuronBaseOCL *NeuronOCL)
  {
   if(!cAdaptSpatNorm.UpdateInputWeights(cShortNorm.AsObject()))
      return false;
   if(!cProjection.UpdateInputWeights(cConcatenated.AsObject()))
      return false;
   for(uint i = 0; i < caFusion.Size(); i++)
      if(!caFusion[i].UpdateInputWeights(cTranspose.AsObject()))
         return false;
//---
   return true;
  }

Thanks to its modular structure, the algorithm for updating weights remains simple and logical, without overburdening the code with unnecessary dependencies.

The complete source code for the CNeuronSCNNEncoder class, including the implementation of all key methods, is provided in the attachment. Thanks to its transparent structure and detailed modular breakdown, this code can serve as a foundation for further experiments and modifications.



Top-Level Module

The authors of the SCNN framework propose using a hierarchical architecture in which multiple encoders are arranged into a single stack. This approach is designed to increase the model's expressiveness and, as a result, improve forecasting quality. Each subsequent encoder in such a chain does not operate in isolation but uses the results of the previous one as input data. The use of residual connections provides additional context for the analysis. As a result, the model can take a broader context into account, perform a deeper analysis, and capture both local and long-term dependencies within a time series.

However, this flexibility comes at a cost. When transitioning from one layer to another, a very specific problem arises — the difference in tensor size between the input and the output. The point is that at the output of each SCNN Encoder, we obtain univariate sequences in a compatible format, but extended by a specified forecast horizon.

This is where a difficult challenge arises. On the one hand, the data for the next layer must be provided without including any forecast values — otherwise, we would be leaking information from the future forward, which is unacceptable. On the other hand, we cannot simply discard these forecast values, as they are needed for the final summation of the outputs from all layers. Simply put, we need to take into account both what the model has already seen and what it has predicted, without disrupting the temporal structure of the data.

To solve this problem, we will create a top-level object — CNeuronSCNN. This is a generalized control element that encapsulates the entire stack structure of the Encoders. Its task is not simply to call the Encoders one by one, but to ensure that the entire architecture functions correctly: to transfer data between layers, align their sizes, accumulate forecasts, and manage training and backpropagation. This element serves as the link between the model's computational logic and architectural consistency.

The structure of the new object is shown below.

class CNeuronSCNN    :  public   CNeuronBaseOCL
  {
protected:
   CLayer            cLayers;
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL)  override;
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL)  override;

public:
                     CNeuronSCNN(void) {};
                    ~CNeuronSCNN(void) {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                          uint units_count, uint variables, uint forecast,
                          uint season_period, uint short_period, uint layers,
                          ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual bool      Save(const int file_handle) override;
   virtual bool      Load(const int file_handle) override;
   //---
   virtual int       Type(void) override const  {  return defNeuronSCNN; }
   virtual void      TrainMode(bool flag) override;
   virtual void      SetOpenCL(COpenCLMy *obj) override;
   //---
   virtual bool      WeightsUpdate(CNeuronBaseOCL *source, float tau) override;
   virtual void      SetActivationFunction(ENUM_ACTIVATION value) override { }
  };

Inside CNeuronSCNN is an object of type CLayer, which essentially serves as a container for all nested Encoders. The class contains a single embedded CLayer object, which allows us to leave the constructor and destructor empty and rely on the runtime for memory management.

To get the entire system up and running, you must correctly assemble the architecture from its components. This task is handled in the Init method, where the entire dynamic structure of the CNeuronSCNN object is created and configured.

bool CNeuronSCNN::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                       uint units_count, uint variables, uint forecast,
                       uint season_period, uint short_period, uint layers,
                       ENUM_OPTIMIZATION optimization_type, uint batch)
  {
   if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, (units_count + forecast)*variables,
                                                                   optimization_type, batch))
      return false;
   SetActivationFunction(None);

It all starts here with base initialization via the method of the same name in the parent class, where the base interfaces of the neural layer are created. Next, the activation function is disabled — it is not required at this level, since the Encoders themselves provide all the necessary nonlinearity and data preprocessing.

After that, the cLayers container is cleared and prepared to hold the necessary objects.

   cLayers.Clear();
   cLayers.SetOpenCL(OpenCL);
   CNeuronSCNNEncoder* encoder = NULL;
   CNeuronBaseOCL* residual = NULL;
   for(uint l = 0; l < layers; l++)
     {
      encoder = new CNeuronSCNNEncoder();
      if(!encoder)
         return false;
      if(!encoder.Init(0, l, OpenCL, units_count, variables, forecast, season_period,
                                                  short_period, optimization, iBatch) ||
         !cLayers.Add(encoder))
         return false;
      encoder.SetActivationFunction(None);
      if((l + 1) == layers)
         break;

A loop over the number of layers (the layers parameter) starts creating Encoders of type CNeuronSCNNEncoder one by one. For each layer, an encoder object is created and initialized with the following parameters: input dimensionality, the number of variables, the forecast length, and the seasonal periods. If initialization is successful, the object is added to the layer container.

However, the work does not end there. To preserve residual connections between layers, additional layers are inserted between the encoders, acting as a kind of alignment buffer. These intermediate blocks take the outputs of the preceding Encoders and discard the forecast values, ensuring continuity of the data flow between levels. Here, the activation function is disabled again — after all, these blocks serve a utility role rather than a computational one.

      residual = new CNeuronBaseOCL();
      if(!residual)
         return false;
      if(!residual.Init(0, l, OpenCL, units_count * variables, optimization, iBatch) ||
         !cLayers.Add(residual))
         return false;
      residual.SetActivationFunction(None);
     }
   if(!SetGradient(encoder.getGradient(), true))
      return false;
//---
   return true;
  }

It is important to note that the final Encoder in the stack does not receive a subsequent utility layer. Its gradient is used as the main one for the entire top layer, which eliminates the need for an extra data-copying operation.

Thus, the Init method transforms from a simple initializer into a sort of object builder that assembles the entire stacked structure of Encoders on the fly, carefully adjusting every detail while taking into account size alignment, forecast horizons, and the management of gradient flow. Thanks to this well-designed architecture, it is possible not only to train the model effectively but also to scale it to real-world tasks, whether that involves short-term price forecasting or long-term analysis of seasonal trends.

Once the initialization phase is complete — when the structure of the multilayer SCNN model has been assembled and is ready for operation — the key step begins: performing a forward pass. This is where the model begins to perform its primary function: processing raw data, generating forecasts, and accumulating information at every level.

bool CNeuronSCNN::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   if(!NeuronOCL)
      return false;
   if(!Output.Fill(0))
      return false;
   CNeuronBaseOCL* inputs = NeuronOCL;
   CNeuronSCNNEncoder* current = NULL;
   CNeuronBaseOCL* residual = NULL;
   int layers = cLayers.Total();

It all starts with verifying that the received source data pointer is valid. If the data is missing, execution is terminated. After that, the results buffer is cleared to prevent the accumulation of artifacts from previous iterations. Next, the inputs variable is initialized with a pointer to an external data source — the one we pass as input to the Encoder. Then the iteration over all layers begins.

The model architecture is designed so that the layers alternate: SCNN Encoders are located at even positions, while residual connection buffers, implemented as basic neural objects, are located at odd positions. That is why the loop step is two: a single pass covers both the Encoder and the corresponding residual block.

   for(int l = 0; l < layers; l += 2)
     {
      current = cLayers[l];
      if(!current ||
         !current.FeedForward(inputs) ||
         !SumAndNormilize(Output, current.getOutput(), Output, current.GetCount(), false, 0, 0, 0, 1))
         return false;
      if((l + 1) == layers)
         break;

At each loop iteration, we retrieve the current Encoder, call its FeedForward method, and then add the module's output to the resulting forecast. Thus, even during the forward pass, the cumulative total of all layer-based predictions begins to take shape.

But if this isn't the final layer, there's still more work to be done. We retrieve the next (odd) object — the residual connection buffer. Its task is to adjust the dimensions of the input data for the next Encoder, eliminating the redundancy caused by the addition of forecast values. This task is handled by the DeConcat method, which separates the forecast portion from the clean history, bringing the data back to the history-only format required by the next Encoder. After that, the new inputs are summed with the previous ones, providing the effect of residual connections, and then normalized. Each layer operates not in isolation, but with regard to the results of the previous layers, receiving a richer representation of the time series.

      uint variables = current.GetWindow();
      uint dimension = inputs.Neurons() / variables;
      uint forecast = current.GetCount() - dimension;
      residual = cLayers[l + 1];
      if(!residual)
         return false;
      if(!DeConcat(residual.getOutput(), current.getPrevOutput(), current.getOutput(),
                   dimension, forecast, variables) ||
         !SumAndNormilize(residual.getOutput(), inputs.getOutput(), residual.getOutput(),
                          dimension, true, 0, 0, 0, 1))
         return false;
      inputs = residual;
     }
//---
   return true;
  }

The data obtained after deconcatenation and normalization are passed to the next Encoder, and this continues until the pass through all levels is complete. The output is an aggregate forecast, the result of the combined work of all the Encoders in the block.

Thus, the feedForward method is a carefully structured process in which each step is aimed at enriching the information, eliminating distortions, and preparing a balanced forecast. It puts the entire philosophy of the stacked architecture into practice: progressively deepening the analysis without losing the historical context and while taking into account the contributions of each level.

After completing the forward pass, we move on to an equally important stage — backpropagation. Here, gradients are propagated from the output layer back to the input data, allowing the model parameters to be optimized. The process is implemented in the calcInputGradients method.

bool CNeuronSCNN::calcInputGradients(CNeuronBaseOCL *NeuronOCL)
  {
   if(!NeuronOCL)
      return false;
   if(!PrevOutput.Fill(0))
      return false;
//---
   CNeuronBaseOCL* inputs = NULL;
   CNeuronSCNNEncoder* current = cLayers[-1];
   CNeuronBaseOCL* residual = NULL;
   int layers = cLayers.Total() - 2;

The procedure begins with basic checks: verifying that there is a pointer to the external NeuronOCL object and clearing the auxiliary PrevOutput buffer. Next, the number of layers involved in training is determined. It is important to understand here that the count proceeds in reverse order, starting with the last Encoder, since the backward pass requires moving from the model’s output to its input.

A loop with a step of "-1" covers all layers, including residual-connection objects, and the appropriate logic is executed for each one depending on the layer type.

   for(int l = layers; l >= 0; l--)
      switch(cLayers[l].Type())
        {
         case defNeuronBaseOCL:
            inputs = cLayers[l];
            if(!inputs ||
               !inputs.CalcHiddenGradients(current))
               return false;
            if(!!residual)
               if(!SumAndNormilize(inputs.getGradient(), residual.getGradient(), inputs.getGradient(),
                                   current.GetWindow(), false, 0, 0, 0, 1))
                  return false;
            residual = inputs;
            break;

If we are dealing with a base CNeuronBaseOCL object, we perform two actions. First, we calculate the hidden-layer gradients based on the current Encoder. Second, if there is a previous residual block (the residual variable), we sum its gradients with the current ones, adjusting the value being passed on. This ensures a continuous flow of information between layers and helps avoid losses caused by the residual structure of the architecture.

However, if the current layer is an SCNN Encoder, the logic becomes more complex. First, we make sure that the residual object has already been defined. If not, we simply move on. Otherwise, we retrieve the object located two layers ahead — that is, the one that supplied data to the input of the current Encoder during the forward pass. This makes it possible to accurately reconstruct the data structure.

 case defNeuronSCNNEncoder:
    current = cLayers[l];
    if(!residual)
       break;
    inputs = cLayers[l + 2];
    if(!inputs)
       return false;
    if(!Concat(residual.getGradient(), PrevOutput, current.getGradient(),
               residual.Neurons() / current.GetWindow(),
               current.GetCount() - residual.Neurons() / current.GetWindow(),
               current.GetWindow()) ||
       !SumAndNormilize(current.getGradient(), inputs.getGradient(), current.getGradient(),
                        current.GetWindow(), false, 0, 0, 0, 1))
       return false;
    break;
 default:
    return false;
    break;
}

Next, using the Concat function, we restore the gradient shape: zero values from its PrevOutput corresponding to the forecast portion are appended to the current gradient of the residual layer. This is necessary because, during the forward pass, the data was split into historical and forecast parts, and now this structure must be accurately reproduced in the error gradient. Next, they are summed with the gradient from the subsequent Encoder — this is an important point, because information can travel through two information streams, and they must be combined into a single form.

After completing the pass through all layers, the final step is performed: passing the gradient to the outermost level. Here, NeuronOCL receives the gradient from the lower SCNN Encoder, and, if a residual layer is present, the gradients are finally summed.

   if(!NeuronOCL.CalcHiddenGradients(current))
      return false;
   if(!!residual)
      if(!SumAndNormilize(NeuronOCL.getGradient(), residual.getGradient(), NeuronOCL.getGradient(),
                          current.GetWindow(), false, 0, 0, 0, 1))
         return false;
//---
   return true;
  }

Thus, the calcInputGradients method embodies the key idea behind the stacked architecture: each layer can adjust its parameters not only based on its own error, but also based on how it affects the model as a whole. This allows for fine-tuning and high sensitivity to the characteristics of time series.

Once the gradients have been computed across all layers of the stacked architecture, the final stage of training begins — updating the weights. This is where all the work done previously is put into practice: we convert the accumulated information about the error into adjustments to the model’s trainable parameters.

The updateInputWeights method is responsible for updating the weights of all trainable blocks in the model step by step. It all starts by initializing the inputs variable with a pointer to an external data source: a NeuronOCL object.

bool CNeuronSCNN::updateInputWeights(CNeuronBaseOCL *NeuronOCL)
  {
   CNeuronBaseOCL* inputs = NeuronOCL;
   CNeuronBaseOCL* current = NULL;
//---
   for(int l = 0; l < cLayers.Total(); l++)
     {
      current = cLayers[l];
      if(!current)
         return false;
      if(current.Type() == defNeuronSCNNEncoder)
         if(!current.UpdateInputWeights(inputs))
            return false;
      inputs = current;
     }
//---
   return true;
  }

Next, the method iterates over all layers of the model. At each loop iteration, the layer type is checked. We are interested only in SCNN Encoders, since they are the components that contain trainable parameters. If the current layer matches this type, the UpdateInputWeights method is called, and the input data is passed to it. This allows the weights to be adjusted correctly based on the computed gradient. If the update succeeds, we replace the pointer in the inputs variable so that it can serve as the input for the next layer. This preserves the logical continuity characteristic of the forward pass and backpropagation.

The updateInputWeights method completes the training loop, allowing each Encoder to adapt to the model's errors and contribute to the overall optimization process. Everything is implemented cleanly and pragmatically, without unnecessary complexity, while fully adhering to the logic of the architecture.

The complete code for the CNeuronSCNN class and all of its methods is provided in the attachment.



Model Architecture

After completing the description of the logic behind the objects used to build the SCNN framework, the next natural step is to delve into the architecture of the model itself, since it is this architecture that determines how accurately and robustly the system can extract patterns from the data. Here, the process of building the neural-network pipeline configuration takes shape in the `CreateDescriptions` method.

This method is responsible for creating and populating layer descriptions — the building blocks from which the computational model is subsequently formed. To put it simply, this is where the layers of the future neural network are laid down, brick by brick: from the input data layer to the Encoders and forecast branches. At the start, we see the creation and initialization of six containers: one for the main environment state Encoder, three for different variants of the forecasting models, and two for the Actor and Critic branches used in reinforcement learning tasks.

The environment state Encoder itself begins with a basic fully connected layer that receives an input data vector generated from historical bars.

bool CreateDescriptions(CArrayObj *&encoder,
                        CArrayObj *&forecast1,
                        CArrayObj *&forecast2,
                        CArrayObj *&forecast3,
                        CArrayObj *&actor,
                        CArrayObj *&critic
                       )
  {
//---
   CLayerDescription *descr;
//---
   if(!encoder)
     {
      encoder = new CArrayObj();
      if(!encoder)
         return false;
     }
   if(!forecast1)
     {
      forecast1 = new CArrayObj();
      if(!forecast1)
         return false;
     }
   if(!forecast2)
     {
      forecast2 = new CArrayObj();
      if(!forecast2)
         return false;
     }
   if(!forecast3)
     {
      forecast3 = new CArrayObj();
      if(!forecast3)
         return false;
     }
   if(!actor)
     {
      actor = new CArrayObj();
      if(!actor)
         return false;
     }
   if(!critic)
     {
      critic = new CArrayObj();
      if(!critic)
         return false;
     }
//--- Encoder
   encoder.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   uint prev_count = descr.count = (HistoryBars * BarDescr);
   descr.activation = None;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

Next, a batch normalization layer is added, with noise introduced during the training phase; this acts as a regularizer and improves the model's robustness to noise in the data.

//--- Layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBatchNormWithNoise;
   descr.count = prev_count;
   descr.batch = BatchSize;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- Layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConcatDiff;
   prev_count = descr.count = HistoryBars;
   descr.layers = BarDescr;
   descr.step = 1;
   descr.batch = BatchSize;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

Next comes the first-difference feature concatenation layer CNeuronConcatDiff, which creates derivative features, helping the network better capture local changes.

The CMamba4CastEmbeding layer deserves special attention as a modern architectural element. It extracts hidden features by taking into account several time windows (in this case, daily and monthly), thereby creating embeddings that account for temporal harmonics. This is where the neural network first thinks about seasonality and long-term trends.

//--- Layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defMamba4CastEmbeding;
   prev_count = descr.count = HistoryBars;
   descr.window = 2 * BarDescr;
   uint prev_out = descr.window_out = NSkills;
     {
      uint temp[] = {PeriodSeconds(PERIOD_D1), PeriodSeconds(PERIOD_MN1)};
      if(ArrayCopy(descr.windows, temp) < (int)temp.Size())
         return false;
     }
   descr.batch = BatchSize;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- Layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronTransposeOCL;
   descr.count = prev_count;
   prev_count = descr.window = prev_out;
   descr.batch = BatchSize;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
   prev_out = descr.count;

Next, the embeddings are transposed into univariate sequence representations.

And the crowning touch of the entire Environment State Encoder design is the integration of the previously described module — the stack of SCNN Encoders. It is precisely this block — built with four levels of nesting and carefully tuned to perceive seasonal and short-term patterns — that forms the core of intelligent information processing.

//--- Layer 5
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronSCNN;
   descr.variables = prev_count;
   {
      uint temp[]={prev_out,NForecast,SeasonPeriod,ShortPeriod};
      if(ArrayCopy(descr.windows,temp)<(int)temp.Size())
        return false;
   }
   descr.count=descr.windows[0]+descr.windows[1];
   descr.layers=4;
   descr.batch = BatchSize;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
   uint variables=descr.variables;
   uint count=descr.count;

Something more is happening here than a simple data transformation. This module takes on the key analytical workload: it not only extracts features but also structures them into multi-layer hidden representations, each of which generalizes the observed patterns at its own scale. At the output, we obtain forecast vectors that have already been enriched with residual connections and the memory of previous levels.

In essence, it is within this component that raw input data is transformed into a high-level description of the market state. Here, abstract behavioral patterns are transformed into numerical structures ready to be interpreted either by forecast modules or by the model's control branches. And it is precisely at this point that the architecture becomes complete — moving from preparation and normalization to analysis, interpretation, and decision-making.

It is worth emphasizing an important technical point here. At the output of the stack of SCNN Encoders, we obtain an array of univariate sequences — this is convenient and logical for subsequent forecasting models. It is ideal for generating forecasts over a specified forecast horizon. However, it turns out to be not entirely appropriate in the context of the analysis that the Actor and Critic modules will perform.

The fact is that, in order to make decisions, the agent must not only know each forecast individually, but also understand them as a coherent temporal structure — a kind of multimodal dynamic that encompasses both short-term and long-term aspects of the system's behavior. This requires a different way of organizing the data: ordering the time steps sequentially while preserving their interrelationships.

That is exactly why we add another transposition layer. This step transforms our tensor from a set of univariate forecasts into a format corresponding to the time steps of a multimodal sequence. Each time point has access to all forecast variables collected from different channels. As a result, the Actor and the Critic can perceive the dynamics of the environment over time as a cohesive whole, rather than as isolated fragments.

//--- layer 6
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronTransposeOCL;
   descr.count = variables;
   prev_count = descr.window = count;
   descr.batch = BatchSize;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

Thus, this final transformation layer completes the architecture of the Encoder and plays a key role in ensuring a proper interface between the forecasting mechanism and the decision-making modules.

The architecture of the forecasting and decision-making models was carried over from our previous projects with virtually no changes. These components have already proven their effectiveness, so we will not go into detail about them in this article. For those interested in gaining a deeper understanding of the architectural solutions, we recommend reviewing the materials included in the attachment.

In addition, the attachment contains the source code for the programs used to train and test the models. These materials will make it possible not only to trace the internal logic behind the system's design, but also to reproduce the full experimental cycle.



Testing

The model training process consists of two consecutive stages. During the first, offline stage, training was conducted on historical data for the EURUSD currency pair on the H1 timeframe for all of 2024. This period encompassed a wide range of market scenarios. The diversity of the data allowed the model to handle both typical and rare market situations.

After completing the offline training, we moved on to the second stage — online fine-tuning performed under conditions as close as possible to the real market. The training took place in the MetaTrader 5 Strategy Tester, where the model analyzed streaming candlestick data step by step. This made it possible not only to test the model's resilience to noise and market distortions, but also to ensure its adaptability to changing conditions. This approach significantly improved the model's robustness, minimized the risk of overfitting, and enhanced its generalization ability.

The process concluded with a test of the model on entirely new data — quotes for the period from January to March 2025. All parameters and settings obtained during training were saved without any changes. Thus, the results obtained provide an objective assessment of both the accuracy and the practical reliability of the proposed method. The test results are presented below.

Over three months of testing, our model demonstrated capital growth from $100 to approximately $430, with the average profit per trade ($13.17) exceeding the average loss ($11.71). The percentage of winning trades is close to 48%, and a profit factor of about 1.04 indicates that the system operates with a slight edge in favor of profit.

At the same time, the maximum drawdown exceeded 82%, with the steepest decline occurring in the second ten-day period of March. This was the period farthest removed from the training sample. This suggests that the model struggles when faced with new market conditions and is not resilient enough to withstand unexpected spikes in volatility.

Overall, the test showed that the SCNN architecture is capable of generating profits and maintaining a balance between risk and return; however, for practical application, additional risk management mechanisms are needed, along with an extended training period. This will enable the model not only to operate consistently in a familiar market, but also to adapt more confidently to new, unpredictable conditions.



Conclusion

In this article, we completed the development and practical testing of an SCNN model for time series forecasting. A great deal of work has been done: from examining the theoretical idea of time series decomposition to building a fully trained stack of Encoders and integrating the proposed approaches into the Actor-Critic architecture.

A backtest on data for the EURUSD currency pair from January through March 2025 confirmed the model's ability to generate profit and produce balanced forecasts; however, it also revealed vulnerability to sharp market changes outside the training sample. The deep drawdowns in March underscore the need for further work to optimize the model.


References


Programs used in the article

# Name Type Description
1 Study.mq5 Expert Advisor Expert Advisor for offline model training
2 StudyOnline.mq5 Expert Advisor Expert Advisor for online model training
3 Test.mq5 Expert Advisor Expert Advisor for model testing
4 Trajectory.mqh Class Library Structure for describing the system state and model architectures
5 NeuroNet.mqh Class Library Class library for building a neural network
6 NeuroNet.cl Library Code library for an OpenCL program


Translated from Russian by MetaQuotes Ltd.
Original article: https://www.mql5.com/ru/articles/19022

Attached files |
MQL5.zip (2956.64 KB)
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.
Combining 3D Bars, Quantum Computing, and Machine Learning into a Unified Trading System Combining 3D Bars, Quantum Computing, and Machine Learning into a Unified Trading System
The article presents the full integration of the 3D-bar module into a quantum-enhanced trading system for forecasting the movement of currency pairs. The system combines stationary four-dimensional features, an 8-qubit quantum encoder, and CatBoost gradient boosting with 52+ features. The system is implemented in Python using MetaTrader 5, Qiskit, CatBoost, and optional integration with the Llama 3.2 LLM for interpreting forecasts.
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
We examine how a Hidden Markov Model (HMM) estimates latent market regimes while basing on observable price and indicator sequences. This is done by estimating the probability of state transitions. A Gated Recurrent Unit (GRU) network models time dependencies and keeps important information over several observations. In an Expert Advisor, HMM-based regime probabilities, can be merged with GRU-based sequence learning to better classify increments in accumulation, distribution, and momentum prior to their showing up in regular price confirmations.
Decoding Market Intent: Reading Structure, Liquidity, and Price Behavior Decoding Market Intent: Reading Structure, Liquidity, and Price Behavior
We implement a five-stage MQL5 pipeline that quantifies market structure, liquidity interaction, and price behavior on four timeframes, then resolves them into a 0–100 Market Intent Score. Decision states (WAIT/WATCH/ACTION) are driven by explicit weights plus hard gates. The analytical core feeds a concise dashboard and, when AutoTrade is on, an execution layer with entry zones, invalidation and liquidity‑based targets.