preview
Building a Neural Loss-Pattern Auditor in MQL5

Building a Neural Loss-Pattern Auditor in MQL5

MetaTrader 5 — Examples |
36 0
Cristian David Castillo Arrieta
Cristian David Castillo Arrieta

Introduction

Your account's closed-deal history in MetaTrader 5 already contains the answer to a more specific question than win rate, profit factor, or maximum drawdown can address: whether the probability of a loss on the next trade depends on what you just did, such as sizing up right after a loss or holding positions longer late in the session. That kind of dependency does not live inside any single trade; it exists only in the sequence and combination of trades, so no summary statistic taken across the whole history can surface it, and no threshold on one feature at a time can confirm it either. Testing behavioral conditions like this in combination, by hand, does not scale. What is needed is a direct, quantitative test of that hypothesis, run on the trader's own history, natively in MetaTrader 5, with no external service.

This article presents the Neural Loss-Pattern Auditor, an MQL5 script that trains a small neural network on a trader's closed-deal history. The goal is to test whether behavioral and market-context features predict which trades are more likely to result in a loss. The network, its training loop, and every diagnostic in the report are written from scratch in native MQL5.

By the end, you will have a working tool that:

  • Trains a compact feed-forward network, one hidden layer with plain backpropagation, on eight engineered features per closed deal, using either a built-in synthetic demo or real account history.
  • Reports whether the network's predictions are actually trustworthy: an accuracy uplift over a naive baseline, a calibration table comparing predicted probabilities against what really happened, and a permutation-importance ranking of which feature the network relies on most.
  • Rolls those diagnostics into one configurable A–F grade with plain-language recommendations.

Everything runs natively inside MetaTrader 5. There is no ONNX model, no Python process, and no external AI service. The network is about 150 lines of MQL5, and it trains inside the script on each OnStart() run.


Why Aggregate Statistics Are Not Enough

Consider two trades on the same symbol, opened at a similar volatility level and around the same time of day. The only difference is context: one follows a string of breakeven trades, and the other follows a loss and is sized 2.5 times larger than the trader's typical volume. A profit factor or win-rate summary treats both trades as two anonymous rows in the same ledger. Neither statistic can see that the second trade belongs to a pattern, a larger size specifically following a loss, because that pattern lives in the relationship between trades, not in any single trade's own outcome.

A single threshold rule, for example flagging any trade above 1.5 times the average size, would catch oversized trades in general. It cannot tell whether being oversized after a loss is riskier than being oversized after a win, or whether the effect only shows up when combined with something else, such as trading late in the session. Testing combinations of conditions by hand does not scale far past two or three features. This is exactly the kind of weak, multi-feature interaction problem a small neural network is suited for, and a fixed rule is not. The hidden layer can learn a combination of inputs, such as "recent loss and oversized," that no single input predicts well on its own.

Neural

Fig. 1. A pattern that only appears once trade sequence and size are considered together, invisible to either feature alone.


Concepts: The Network, Calibration, and Feature Importance

The network is a small feed-forward classifier: an input layer of eight engineered features, one hidden layer of tanh neurons, and a single sigmoid output interpreted as the predicted probability that a closing deal is a loss. For a feature vector x with n inputs and h hidden neurons, the forward pass is:

hidden_j = tanh( b_h_j + Sum_i( w_ih[j,i] * x_i ) )  — for each hidden neuron j

output = sigmoid( b_o + Sum_j( w_ho_j * hidden_j ) ), where sigmoid(z) = 1 / (1 + exp(−z))

Training uses plain stochastic gradient descent on binary cross-entropy loss, one closed deal at a time:

loss = −( y * ln(p) + (1 − y) * ln(1 − p) )

where y is the true label (1 for a loss, 0 otherwise) and p is the network's predicted probability. Because the output layer is sigmoid and the loss is binary cross-entropy, the gradient with respect to the pre-sigmoid logit collapses to the simple form (p − y). This shortcut is specific to this exact pairing of output activation and loss function, and it would not hold if either one changed. The hidden-layer gradient then follows from the standard tanh derivative, (1 − tanh^2).

Two of the eight input features, hour_sin and hour_cos, encode the hour of day as a pair of coordinates on a circle instead of a single 0–23 integer. A plain integer hour would place 23:00 and 00:00 at opposite ends of the input range even though they are one hour apart. The circular encoding keeps every pair of adjacent hours the same distance apart, including the wrap-around from 23:00 to 00:00.

A network that fits the training data well is not automatically useful. Three further checks separate a genuine pattern from an overfit one.

Accuracy uplift. The network's classification accuracy on a held-out validation set is compared against the accuracy of always guessing the majority class, where the majority class is measured on the training set only. A network that cannot beat that trivial baseline is not adding information.

Calibration. Validation predictions are grouped into probability bins, and each bin's average predicted probability is compared against the actual observed loss frequency in that bin. The mean absolute calibration error is a count-weighted average across bins:

calib_error = Sum_b( n_b * |pred_avg_b − actual_freq_b| ) / Sum_b( n_b )

where n_b is the number of validation examples in bin b. A well-calibrated network's 70%-confidence predictions really do turn out to be losses about 70% of the time. A poorly calibrated network can still separate the two classes reasonably well while being unreliable as a probability.

Permutation feature importance. For each feature, the tool shuffles that column across the validation set, measures how far accuracy drops, and repeats the shuffle several times to average out noise:

importance_f = base_accuracy − average_over_repeats( accuracy_with_feature_f_shuffled )

A feature the network actually relies on causes a real, repeatable accuracy drop when scrambled. A feature it ignores averages out close to zero, and can even show a small negative value from noise alone. This is a model-agnostic technique: it works by testing the trained network's behavior from the outside, not by inspecting its weights directly.

The three checks are combined into a 0–100 composite score and a letter grade. This score is a heuristic for ranking and comparison; it is not a statistically validated measure:

uplift_score = 50 + 50 * clamp( uplift / uplift_scale, −1, +1 )

calib_score = 100 * clamp( 1 − calib_error / calib_error_scale, 0, 1 )

feature_score = 100 * clamp( top_importance / feature_scale, 0, 1 )

composite = w_uplift * uplift_score + w_calib * calib_score + w_feature * feature_score

where clamp(x, lo, hi) restricts x to the [lo, hi] range. w_uplift, w_calib, and w_feature are the three weight inputs, renormalized to sum to 1, so the result does not depend on whether the user happened to enter weights that already summed to exactly 1. The three *_scale inputs set how large an uplift, calibration error, or feature importance counts as a full 0 or 100 on that sub-score. Those inputs, along with the four grade cutoffs (A at 85 and down to F below 40 by default), are ordinary script inputs, not hidden constants.


Architecture of the Neural Loss-Pattern Auditor

The project consists of three files that should be placed in the same folder. NLPA_NeuralNet.mqh is included via a quoted relative path, so all files should be in the MQL5\Scripts\NeuralLossPatternAuditor folder. If you move the include file, you must update the #include path.

  1. NLPA_NeuralNet.mqh: the reusable CMlpNet class — Init(), Forward(), and TrainStep() — with no dependency on trading functions of any kind. It only knows about feature vectors and labels.
  2. NLPA_NeuralNetSelfTest.mq5: a standalone script that trains the same engine on a synthetic XOR-sign problem to confirm the hidden layer and the backpropagation update are wired correctly, independent of anything specific to trade data.
  3. NeuralLossPatternAuditor.mq5: the main tool. It builds the feature and label dataset from either synthetic demo data or real account history, trains the network, and prints the full diagnostic report.

Module 1: The Network Engine (NLPA_NeuralNet.mqh)

CMlpNet stores its weights as flat arrays rather than a nested structure. MQL5 does not support arrays of arrays as class members, which a nested per-neuron structure would otherwise need. Init() draws the initial weights from a uniform range scaled by 1 / sqrt(fan_in), a standard, simple initialization rule that keeps the first forward pass in a sane range regardless of how many inputs or hidden neurons are configured. TrainStep() calls Forward() internally, reuses the hidden-layer activations it just computed for the backward pass, and returns the example's loss, so the caller can track whether training is converging.

//+------------------------------------------------------------------+
//|                                               NLPA_NeuralNet.mqh |
//|                                      Neural Loss-Pattern Auditor |
//|                                             https://www.mql5.com |
//+------------------------------------------------------------------+
#property  copyright "Neural Loss-Pattern Auditor"
#property  link      "https://www.mql5.com"

//+------------------------------------------------------------------+
//| CMlpNet A small feed-forward network with one tanh hidden layer  |
//| and one sigmoid output neuron, trained by plain backpropagation  |
//| written directly in MQL5. No ONNX file, no Python process, no    |
//| external service of any kind: Init() builds the weight arrays,   |
//| Forward() runs a prediction, and TrainStep() performs one        |
//| gradient-descent update from a single labeled example.           |
//+------------------------------------------------------------------+
class CMlpNet
  {
private:
   int      m_n_in;     // number of input features
   int      m_n_hidden; // number of hidden neurons
   double   m_w_ih[];   // input->hidden weights, flattened [hidden*in]
   double   m_b_h[];    // hidden-layer biases
   double   m_w_ho[];   // hidden->output weights
   double   m_b_o;      // output bias
   double   m_hidden[]; // scratch: hidden activations from the last Forward() call

   double            Sigmoid(const double x) const;
   double            TanhSafe(const double x) const;
   double            UniformWeight(const double scale);

public:
   void              Init(const int n_in,const int n_hidden,const int seed);
   double            Forward(const double &features[]);
   double            TrainStep(const double &features[],const double label,const double lr);
   int               HiddenCount(void) const { return m_n_hidden; }
  };

//+------------------------------------------------------------------+
//| Sigmoid, clamped before MathExp so a large logit cannot overflow |
//+------------------------------------------------------------------+
double CMlpNet::Sigmoid(const double x) const
  {
   double c=MathMax(-30.0,MathMin(30.0,x));
   return 1.0/(1.0+MathExp(-c));
  }

//+------------------------------------------------------------------+
//| tanh via MathExp, clamped the same way as Sigmoid()              |
//+------------------------------------------------------------------+
double CMlpNet::TanhSafe(const double x) const
  {
   double c=MathMax(-30.0,MathMin(30.0,x));
   double e2=MathExp(2.0*c);
   return (e2-1.0)/(e2+1.0);
  }

//+------------------------------------------------------------------+
//| One weight drawn from Uniform(-scale, +scale) using MathRand()   |
//+------------------------------------------------------------------+
double CMlpNet::UniformWeight(const double scale)
  {
   return (MathRand()/32767.0)*2.0*scale-scale;
  }

//+------------------------------------------------------------------+
//| Allocate the weight arrays and draw the initial weights. Scaling |
//| by 1/sqrt(fan_in) (a standard, simple init rule) keeps the first |
//| forward pass in a sane range regardless of n_in. Call            |
//| MathSrand(seed) yourself first if you need the run to be         |
//| reproducible; Init() does not reseed on its own, so a caller can |
//| draw other random numbers (for example synthetic demo data) from |
//| the same seeded stream before or after building the net.         |
//+------------------------------------------------------------------+
void CMlpNet::Init(const int n_in,const int n_hidden,const int seed)
  {
   m_n_in    =n_in;
   m_n_hidden=n_hidden;
   ArrayResize(m_w_ih,n_hidden*n_in);
   ArrayResize(m_b_h,n_hidden);
   ArrayResize(m_w_ho,n_hidden);
   ArrayResize(m_hidden,n_hidden);

   MathSrand(seed);
   double scale_ih=1.0/MathSqrt((double)n_in);
   double scale_ho=1.0/MathSqrt((double)n_hidden);

   for(int j=0; j<n_hidden; j++)
     {
      for(int i=0; i<n_in; i++)
         m_w_ih[j*n_in+i]=UniformWeight(scale_ih);
      m_b_h[j] =0.0;
      m_w_ho[j]=UniformWeight(scale_ho);
     }
   m_b_o=0.0;
  }

//+------------------------------------------------------------------+
//| Forward pass: features[] (length m_n_in) -> predicted P(loss).   |
//| Also refreshes m_hidden[], which TrainStep() reuses so the       |
//| backward pass never has to recompute the forward activations.    |
//+------------------------------------------------------------------+
double CMlpNet::Forward(const double &features[])
  {
   for(int j=0; j<m_n_hidden; j++)
     {
      double s=m_b_h[j];
      for(int i=0; i<m_n_in; i++)
         s+=m_w_ih[j*m_n_in+i]*features[i];
      m_hidden[j]=TanhSafe(s);
     }
   double s_o=m_b_o;
   for(int j=0; j<m_n_hidden; j++)
      s_o+=m_w_ho[j]*m_hidden[j];
   return Sigmoid(s_o);
  }

//+------------------------------------------------------------------+
//| One SGD step on binary cross-entropy loss for a single example.  |
//| Because the output layer is sigmoid and the loss is BCE, the     |
//| gradient with respect to the pre-sigmoid logit collapses to the  |
//| clean form (prediction - label); the hidden-layer gradient then  |
//| follows from the standard tanh derivative (1 - tanh^2). Returns  |
//| the example's loss so the caller can track convergence.          |
//+------------------------------------------------------------------+
double CMlpNet::TrainStep(const double &features[],const double label,const double lr)
  {
   double pred=Forward(features);
   double d_out=pred-label;

   double d_hidden[];
   ArrayResize(d_hidden,m_n_hidden);
   for(int j=0; j<m_n_hidden; j++)
      d_hidden[j]=d_out*m_w_ho[j]*(1.0-m_hidden[j]*m_hidden[j]);

//--- output-layer update
   for(int j=0; j<m_n_hidden; j++)
      m_w_ho[j]-=lr*d_out*m_hidden[j];
   m_b_o-=lr*d_out;

//--- hidden-layer update
   for(int j=0; j<m_n_hidden; j++)
     {
      for(int i=0; i<m_n_in; i++)
         m_w_ih[j*m_n_in+i]-=lr*d_hidden[j]*features[i];
      m_b_h[j]-=lr*d_hidden[j];
     }

   double eps=1e-9;
   double loss=-(label*MathLog(pred+eps)+(1.0-label)*MathLog(1.0-pred+eps));
   return loss;
  }
//+------------------------------------------------------------------+

Module 2: A Self-Test for the Network Engine (NLPA_NeuralNetSelfTest.mq5)

Before trusting CMlpNet on real trade data, it is worth confirming the hidden layer and the backpropagation update are actually implemented correctly. NLPA_NeuralNetSelfTest.mq5 does this with a classic test: label a pair of coordinates in [−1, +1] as 1 when they have opposite signs and 0 when they share a sign. This XOR-sign pattern is not linearly separable: no single straight-line decision boundary can classify it well, so a network with no working hidden layer, equivalent to plain logistic regression, cannot do much better than chance on it no matter how it is trained. A network that achieves high accuracy on this task provides good evidence that the hidden layer and its gradient are both implemented correctly, independent of anything specific to the main auditor's own features.

//+------------------------------------------------------------------+
//|                                       NLPA_NeuralNetSelfTest.mq5 |
//|                                      Neural Loss-Pattern Auditor |
//|                                             https://www.mql5.com |
//+------------------------------------------------------------------+
#property  copyright "Neural Loss-Pattern Auditor"
#property  link      "https://www.mql5.com"
#property  version   "1.00"
#property  script_show_inputs

#include  "NLPA_NeuralNet.mqh"

//--- inputs: a self-contained sanity check, independent of any account history
input int    InpXorSeed       =7;    // random seed (data + weight init)
input int    InpXorHidden     =6;    // hidden neurons
input int    InpXorTrainCount =160;  // synthetic training examples
input int    InpXorTestCount  =60;   // synthetic held-out test examples
input int    InpXorEpochs     =500;  // training epochs
input double InpXorLearnRate  =0.30; // learning rate
input double InpXorPassBar    =0.75; // minimum test accuracy counted as a pass

//+------------------------------------------------------------------+
//| Fill one XOR-sign example: two coordinates in [-1,+1], labeled 1 |
//| when they have opposite signs and 0 when they share a sign. This |
//| pattern is not linearly separable, so a network that solves it   |
//| well is good evidence the hidden layer and backpropagation in    |
//| NLPA_NeuralNet.mqh are wired correctly, independent of anything  |
//| specific to the main auditor's trade-history features.           |
//+------------------------------------------------------------------+
void MakeXorExample(double &features[],double &label)
  {
   double f0=(MathRand()/32767.0)*2.0-1.0;
   double f1=(MathRand()/32767.0)*2.0-1.0;
   features[0]=f0;
   features[1]=f1;
   label=((f0>0.0)!=(f1>0.0)) ? 1.0 : 0.0;
  }

//+------------------------------------------------------------------+
//| Accuracy of a trained net over a freshly generated batch of      |
//| n_samples XOR-sign examples, using a 0.5 decision threshold.     |
//+------------------------------------------------------------------+
double XorAccuracy(CMlpNet &net,const int n_samples)
  {
   int correct=0;
   double features[2];
   double label;
   for(int k=0; k<n_samples; k++)
     {
      MakeXorExample(features,label);
      double pred=net.Forward(features);
      int predicted_class=(pred>=0.5) ? 1 : 0;
      if(predicted_class==(int)label)
         correct++;
     }
   return (double)correct/(double)n_samples;
  }

//+------------------------------------------------------------------+
//| Script program start function                                    |
//+------------------------------------------------------------------+
void OnStart()
  {
//--- CMlpNet::Init() seeds MathSrand() itself, so the weight draws and every
//--- MakeXorExample() call that follows are one single reproducible stream
   CMlpNet net;
   net.Init(2,InpXorHidden,InpXorSeed);

//--- build a fixed training batch once, then sweep it InpXorEpochs times
   double train_features[][2];
   double train_labels[];
   ArrayResize(train_features,InpXorTrainCount);
   ArrayResize(train_labels,InpXorTrainCount);
   for(int k=0; k<InpXorTrainCount; k++)
     {
      double f[2];
      double lbl;
      MakeXorExample(f,lbl);
      train_features[k][0]=f[0];
      train_features[k][1]=f[1];
      train_labels[k]     =lbl;
     }

   double first_epoch_loss=0.0;
   double last_epoch_loss =0.0;
   for(int epoch=0; epoch<InpXorEpochs; epoch++)
     {
      double total_loss=0.0;
      for(int k=0; k<InpXorTrainCount; k++)
        {
         double f[2];
         f[0]=train_features[k][0];
         f[1]=train_features[k][1];
         total_loss+=net.TrainStep(f,train_labels[k],InpXorLearnRate);
        }
      double avg_loss=total_loss/InpXorTrainCount;
      if(epoch==0)
         first_epoch_loss=avg_loss;
      if(epoch==InpXorEpochs-1)
         last_epoch_loss=avg_loss;
     }

//--- accuracy on the training batch itself, then on a fresh held-out batch
   int train_correct=0;
   for(int k=0; k<InpXorTrainCount; k++)
     {
      double f[2];
      f[0]=train_features[k][0];
      f[1]=train_features[k][1];
      double pred=net.Forward(f);
      int predicted_class=(pred>=0.5) ? 1 : 0;
      if(predicted_class==(int)train_labels[k])
         train_correct++;
     }
   double train_acc=(double)train_correct/(double)InpXorTrainCount;
   double test_acc =XorAccuracy(net,InpXorTestCount);

   Print("=== NLPA Neural Net Self-Test (XOR-sign sanity check) ===");
   PrintFormat("Architecture: 2 inputs -> %d hidden (tanh) -> 1 output (sigmoid)",InpXorHidden);
   PrintFormat("Training loss: first epoch=%.4f, last epoch=%.4f",first_epoch_loss,last_epoch_loss);
   PrintFormat("Train accuracy (%d examples): %.2f%%",InpXorTrainCount,train_acc*100.0);
   PrintFormat("Held-out test accuracy (%d fresh examples): %.2f%%",InpXorTestCount,test_acc*100.0);
   if(test_acc>=InpXorPassBar)
      PrintFormat("RESULT: PASS -- test accuracy is at or above the %.0f%% bar. A purely linear model "+
                  "cannot separate XOR-sign data much beyond chance, so this result is evidence the "+
                  "hidden layer and the backpropagation update are both implemented correctly.",InpXorPassBar*100.0);
   else
      PrintFormat("RESULT: FAIL -- test accuracy is below the %.0f%% bar. Re-check the backpropagation "+
                  "signs and the learning rate before trusting NLPA_NeuralNet.mqh on real data.",InpXorPassBar*100.0);
  }
//+------------------------------------------------------------------+

Running NLPA_NeuralNetSelfTest.mq5 with its default inputs prints the following report to the Experts tab:


NLPA

Fig. 2. The self-test script confirming the network engine separates a non-linearly-separable pattern well above chance.


The Main Script: Building the Feature Dataset

NeuralLossPatternAuditor.mq5 starts with its inputs, the eight-feature record structure, and a set of small helpers, shown here together before the two dataset builders that use them.

//+------------------------------------------------------------------+
//|                                     NeuralLossPatternAuditor.mq5 |
//|                                      Neural Loss-Pattern Auditor |
//|                                             https://www.mql5.com |
//+------------------------------------------------------------------+
#property  copyright "Neural Loss-Pattern Auditor"
#property  link      "https://www.mql5.com"
#property  version   "1.00"
#property  script_show_inputs

#include  "NLPA_NeuralNet.mqh"

#define  NLPA_N_FEATURES 8

//--- data source ------------------------------------------------------
input bool   InpUseDemoData       =true;  // Use the built-in synthetic demo instead of real account history
input int    InpDemoTradeCount    =480;   // Synthetic trades to generate when InpUseDemoData is true
input int    InpMinTrades         =60;    // Minimum closed trades required before training a model
input double InpValidationFraction=0.25;  // Fraction of the most recent trades held out for validation

//--- network and training ---------------------------------------------
input int    InpHiddenNeurons     =5;     // Hidden neurons (one tanh layer)
input double InpLearningRate      =0.03;  // Learning rate for plain SGD
input int    InpEpochs            =400;   // Full sweeps over the training set
input int    InpRandomSeed        =42;    // Seed for weight init and (in demo mode) synthetic data

//--- diagnostics --------------------------------------------------------
input int    InpCalibrationBins   =5;     // Probability buckets in the calibration report
input int    InpPermutationRepeats=25;    // Shuffle repeats per feature when measuring importance

//--- composite grade -----------------------------------------------------
input double InpWeightUplift      =0.50;  // Score weight: accuracy uplift over the base rate
input double InpWeightCalibration =0.30;  // Score weight: calibration quality
input double InpWeightTopFeature  =0.20;  // Score weight: strength of the top feature
input double InpUpliftScale       =0.10;  // Uplift (accuracy - base rate) that maps to a full 100
input double InpCalibErrorScale   =0.30;  // Mean absolute calibration error that maps to a zero score
input double InpFeatureScale      =0.045; // Top feature importance that maps to a full 100
input double InpGradeA            =85.0;  // Minimum composite score for grade A
input double InpGradeB            =70.0;  // Minimum composite score for grade B
input double InpGradeC            =55.0;  // Minimum composite score for grade C
input double InpGradeD            =40.0;  // Minimum composite score for grade D

//--- real-data feature engineering (ignored when InpUseDemoData is true) ---
input int    InpAtrPeriod         =14;    // ATR period for the volatility-context feature
input int    InpAtrBaselinePeriod =200;   // Longer ATR lookback used as the baseline for that ratio

Every closing deal becomes one STradeRecord: an eight-value feature vector, a label (1.0 for a losing deal net of swap and commission, 0.0 otherwise), and the deal's close time, kept only so records can be sorted chronologically before the train/validation split.

Small helpers

FeatureName() maps a feature index to its name for the report, and PadRight() right-pads a string so the feature-importance columns line up in the printed output.

//+------------------------------------------------------------------+
//| Feature index -> name, used everywhere a feature is reported.    |
//+------------------------------------------------------------------+
string FeatureName(const int idx)
  {
   switch(idx)
     {
      case 0: return "hour_sin";
      case 1: return "hour_cos";
      case 2: return "day_norm";
      case 3: return "atr_ratio";
      case 4: return "size_ratio";
      case 5: return "holding_norm";
      case 6: return "prior_loss_flag";
      case 7: return "daily_seq_norm";
     }
   return "unknown";
  }
//+------------------------------------------------------------------+
//| Right-pad a string with spaces so report columns line up.        |
//+------------------------------------------------------------------+
string PadRight(const string s,const int width)
  {
   string out=s;
   while(StringLen(out)<width)
      out+=" ";
   return out;
  }

RandUniform01() and RandNormal() give the synthetic-data generator a seeded, reproducible source of random numbers. RandNormal() builds a Gaussian draw from two uniform draws using the Box-Muller transform:

z = sqrt( −2 * ln(u1) ) * cos( 2 * pi * u2 ), x = mean + stddev * z

assuming u1 and u2 are independent draws from Uniform(0, 1). u1 is floored just above zero before the logarithm, so an unlucky draw of exactly 0 cannot produce negative infinity.

//+------------------------------------------------------------------+
//| Uniform double in [0, 1] from MathRand().                        |
//+------------------------------------------------------------------+
double RandUniform01(void)
  {
   return MathRand()/32767.0;
  }
//+------------------------------------------------------------------+
//| Gaussian double via the Box-Muller transform, built on           |
//| RandUniform01() so it shares the one seeded MathRand() stream.   |
//+------------------------------------------------------------------+
double RandNormal(const double mean,const double stddev)
  {
   double u1=MathMax(1e-12,RandUniform01());
   double u2=RandUniform01();
   double z =MathSqrt(-2.0*MathLog(u1))*MathCos(2.0*M_PI*u2);
   return mean+stddev*z;
  }

BuildDemoDataset(): a synthetic dataset with one injected pattern

BuildDemoDataset() generates InpDemoTradeCount synthetic closing deals with a single deliberately injected pattern: after a loss, a trade sized above 1.3 times the trader's typical volume loses with probability 0.80, against a 0.42 baseline loss probability otherwise. Two much weaker effects are layered on top. A higher-than-average ATR ratio adds 0.02 to the loss probability, and longer holding times add up to 0.015 times a capped multiple, a mild proxy for the familiar "hope bias" of holding a losing trade too long. The eighth feature, daily_seq_norm, is generated as pure noise in demo mode, shaped like the other ratio features but carrying no same-day-count meaning here, so the report has a genuine distractor to rank low. These constants were chosen so the built-in demo lands on a believable middle grade rather than a trivial pass or fail. See the Applicability and Limitations section for how that grade varies across random seeds.

//+------------------------------------------------------------------+
//| Build InpDemoTradeCount synthetic closing deals with a           |
//| deliberately injected pattern: after a loss, a trader who then   |
//| sizes up (size_ratio > 1.3) loses much more often than usual.    |
//| Every other feature carries only mild or no relationship to the  |
//| outcome, so the report below has a real but imperfect signal to  |
//| find -- exactly the point of a demo. See the article text for    |
//| the exact constants and why they were chosen.                    |
//+------------------------------------------------------------------+
int BuildDemoDataset(STradeRecord &records[])
  {
   MathSrand(InpRandomSeed);
   ArrayResize(records,InpDemoTradeCount);

   double day_weights[7]={18,20,20,20,18,2,2};
   double day_cum[7];
   double running=0.0;
   for(int d=0; d<7; d++)
     {
      running+=day_weights[d];
      day_cum[d]=running;
     }
   double day_total=running;

   double   prior_loss=0.0;
   datetime t=D'2024.01.01 08:00:00';

   for(int k=0; k<InpDemoTradeCount; k++)
     {
      int hour=(int)MathFloor(RandUniform01()*24.0);
      if(hour>23) hour=23;

      double roll=RandUniform01()*day_total;
      int    day=6;
      for(int d=0; d<7; d++)
         if(roll<=day_cum[d])
           {
            day=d;
            break;
           }

      double atr_ratio=MathMax(0.3,RandNormal(1.0,0.28));
      double base_size=MathMax(0.15,RandNormal(1.0,0.22));

      //--- revenge-sizing behavior: after a loss, a chance of a size jump
      double size_ratio=base_size;
      if(prior_loss==1.0 && RandUniform01()<0.40)
         size_ratio=base_size*(1.4+RandUniform01()*0.7);

      double holding_hours =MathMax(0.1,MathExp(RandNormal(1.1,0.7)));
      double daily_seq_norm=MathMax(0.2,RandNormal(1.0,0.25)); // pure noise distractor

      //--- true underlying loss probability for this synthetic trade
      double p_loss=0.42;
      if(atr_ratio>1.3)
         p_loss+=0.02;                                         // small secondary effect
      p_loss+=0.015*MathMin(holding_hours/10.0,1.5);           // mild "hope bias" effect
      if(prior_loss==1.0 && size_ratio>1.3)
         p_loss=0.80;                                          // the real, learnable pattern
      p_loss=MathMax(0.03,MathMin(0.97,p_loss));

      double label=(RandUniform01()<p_loss) ? 1.0 : 0.0;

      records[k].features[0]=MathSin(2.0*M_PI*hour/24.0);
      records[k].features[1]=MathCos(2.0*M_PI*hour/24.0);
      records[k].features[2]=day/6.0;
      records[k].features[3]=atr_ratio;
      records[k].features[4]=size_ratio;
      records[k].features[5]=MathLog(1.0+holding_hours)/MathLog(1.0+48.0);
      records[k].features[6]=prior_loss;
      records[k].features[7]=daily_seq_norm;
      records[k].label       =label;
      records[k].time         =t;

      prior_loss=label;
      t+=6*3600;                                               // space synthetic deals six hours apart so time stays strictly increasing
     }
   return InpDemoTradeCount;
  }

BuildRealDataset(): reading a trader's own account history

BuildRealDataset() reads the same eight features from real account history through HistorySelect() and the HistoryDealGet*() functions. MetaTrader 5 trade history is recorded at the deal level, not the position level: a position closed through several partial exits produces several closing deals, and this function builds one STradeRecord per closing deal, not per position. Only DEAL_TYPE_BUY and DEAL_TYPE_SELL deals with DEAL_ENTRY_OUT or DEAL_ENTRY_OUT_BY are kept, which excludes balance, credit, and correction entries.

Each feature is computed directly from history rather than approximated. hour_sin and hour_cos come from the deal's own close time, and day_norm from its day of week. atr_ratio compares the ATR at close time against a longer same-symbol baseline average, both read through iATR() and CopyBuffer(). ATR handles are created once per symbol and cached, then released with IndicatorRelease() after the loop completes, instead of being recreated for every deal. size_ratio compares the deal's volume against a rolling window of the trader's own recent closing volumes. holding_norm is computed from the close time minus the position's own open time, looked up by position ID from a first pass over the history. That way, a partial exit's holding time reflects when the position actually opened, not just the most recent partial fill. prior_loss_flag carries the previous record's own label forward. daily_seq_norm counts how many closing deals already happened earlier the same calendar day, divided by eight and capped at 2.0, so the feature saturates once a day already has 16 or more closing deals.

//+------------------------------------------------------------------+
//| Build one record per closing deal (DEAL_ENTRY_OUT / OUT_BY) from |
//| real account history. MetaTrader 5 history is deal-level, so a   |
//| position closed through several partial exits contributes        |
//| several records here, one per exit -- not one record per         |
//| position. Buy/sell deals only; balance, credit, and correction   |
//| entries are excluded by the DEAL_TYPE filter.                    |
//+------------------------------------------------------------------+
int BuildRealDataset(STradeRecord &records[])
  {
   if(!HistorySelect(0,TimeCurrent()))
     {
      PrintFormat("ERROR: HistorySelect failed, error code %d.",GetLastError());
      ArrayResize(records,0);
      return 0;
     }
   int total=HistoryDealsTotal();

//--- pass 1: each position's opening time, keyed by position id, so a
//--- closing deal can look up how long ITS position was actually open
   ulong    pos_ids[];
   datetime pos_open_time[];
   int      pos_count=0;
   for(int i=0; i<total; i++)
     {
      ulong ticket=HistoryDealGetTicket(i);
      if(ticket==0)
         continue;
      if(HistoryDealGetInteger(ticket,DEAL_ENTRY)!=DEAL_ENTRY_IN)
         continue;
      long deal_type=HistoryDealGetInteger(ticket,DEAL_TYPE);
      if(deal_type!=DEAL_TYPE_BUY && deal_type!=DEAL_TYPE_SELL)
         continue;

      ArrayResize(pos_ids,pos_count+1);
      ArrayResize(pos_open_time,pos_count+1);
      pos_ids[pos_count]      =(ulong)HistoryDealGetInteger(ticket,DEAL_POSITION_ID);
      pos_open_time[pos_count]=(datetime)HistoryDealGetInteger(ticket,DEAL_TIME);
      pos_count++;
     }

//--- pass 2: one record per closing deal, in the chronological order
//--- HistorySelect() already returns deals in
   ArrayResize(records,0);
   int      n_records   =0;
   double   prior_loss  =0.0;
   double   recent_volumes[20];
   int      recent_cap  =20;
   int      recent_n    =0;
   int      recent_head =0;
   double   recent_sum  =0.0;
   ArrayInitialize(recent_volumes,0.0);

   int    daily_seq  =0;
   int    last_day   =-1, last_mon=-1, last_year=-1;

   string atr_symbols[];
   int    atr_handles[];
   int    atr_symbol_count=0;

   for(int i=0; i<total; i++)
     {
      ulong ticket=HistoryDealGetTicket(i);
      if(ticket==0)
         continue;

      long deal_type =HistoryDealGetInteger(ticket,DEAL_TYPE);
      long deal_entry=HistoryDealGetInteger(ticket,DEAL_ENTRY);
      if(deal_type!=DEAL_TYPE_BUY && deal_type!=DEAL_TYPE_SELL)
         continue;
      if(deal_entry!=DEAL_ENTRY_OUT && deal_entry!=DEAL_ENTRY_OUT_BY)
         continue;

      datetime deal_time=(datetime)HistoryDealGetInteger(ticket,DEAL_TIME);
      double   volume    =HistoryDealGetDouble(ticket,DEAL_VOLUME);
      double   net_result=HistoryDealGetDouble(ticket,DEAL_PROFIT)
                          +HistoryDealGetDouble(ticket,DEAL_SWAP)
                          +HistoryDealGetDouble(ticket,DEAL_COMMISSION);
      string   symbol    =HistoryDealGetString(ticket,DEAL_SYMBOL);
      ulong    pos_id     =(ulong)HistoryDealGetInteger(ticket,DEAL_POSITION_ID);

      //--- this position's open time (falls back to the deal's own time if
      //--- the opening leg is not in the selected history window)
      datetime open_time=deal_time;
      for(int p=0; p<pos_count; p++)
         if(pos_ids[p]==pos_id)
           {
            open_time=pos_open_time[p];
            break;
           }

      MqlDateTime dts;
      TimeToStruct(deal_time,dts);

      //--- ATR ratio: ATR at close time vs. a longer same-symbol baseline
      int handle=INVALID_HANDLE;
      for(int s=0; s<atr_symbol_count; s++)
         if(atr_symbols[s]==symbol)
           {
            handle=atr_handles[s];
            break;
           }
      if(handle==INVALID_HANDLE)
        {
         handle=iATR(symbol,PERIOD_H1,InpAtrPeriod);
         ArrayResize(atr_symbols,atr_symbol_count+1);
         ArrayResize(atr_handles,atr_symbol_count+1);
         atr_symbols[atr_symbol_count] =symbol;
         atr_handles[atr_symbol_count] =handle;
         atr_symbol_count++;
        }

      double atr_ratio=1.0; // neutral default when ATR is unavailable
      if(handle!=INVALID_HANDLE)
        {
         int shift=iBarShift(symbol,PERIOD_H1,deal_time);
         if(shift>=0)
           {
            double atr_now[1];
            double atr_series[];
            if(CopyBuffer(handle,0,shift,1,atr_now)==1)
              {
               int copied=CopyBuffer(handle,0,shift,InpAtrBaselinePeriod,atr_series);
               double base_sum=0.0;
               int    base_count=0;
               for(int c=0; c<copied; c++)
                  if(atr_series[c]>0.0)
                    {
                     base_sum+=atr_series[c];
                     base_count++;
                    }
               if(base_count>0 && atr_now[0]>0.0)
                  atr_ratio=atr_now[0]/(base_sum/base_count);
              }
           }
        }

      //--- size ratio: this deal's volume vs. a rolling window of recent
      //--- closing volumes (a simple circular buffer, up to recent_cap deep)
      double size_ratio=1.0;
      if(recent_n>0)
        {
         double avg_recent=recent_sum/recent_n;
         if(avg_recent>0.0)
            size_ratio=volume/avg_recent;
        }
      if(recent_n==recent_cap)
         recent_sum-=recent_volumes[recent_head];
      else
         recent_n++;
      recent_volumes[recent_head]=volume;
      recent_sum+=volume;
      recent_head=(recent_head+1)%recent_cap;

      //--- holding time: close time minus the POSITION's open time, not just
      //--- this deal's own timestamp, so partial exits are handled correctly
      double holding_hours=MathMax(0.01,(double)(deal_time-open_time)/3600.0);
      double holding_norm =MathLog(1.0+holding_hours)/MathLog(1.0+48.0);

      //--- how many closing deals already happened earlier on this same day
      if(dts.day!=last_day || dts.mon!=last_mon || dts.year!=last_year)
        {
         daily_seq=0;
         last_day =dts.day;
         last_mon =dts.mon;
         last_year=dts.year;
        }
      double daily_seq_norm=MathMin((double)daily_seq/8.0,2.0);
      daily_seq++;

      double label=(net_result<0.0) ? 1.0 : 0.0;

      ArrayResize(records,n_records+1);
      records[n_records].features[0]=MathSin(2.0*M_PI*dts.hour/24.0);
      records[n_records].features[1]=MathCos(2.0*M_PI*dts.hour/24.0);
      records[n_records].features[2]=dts.day_of_week/6.0;
      records[n_records].features[3]=atr_ratio;
      records[n_records].features[4]=size_ratio;
      records[n_records].features[5]=holding_norm;
      records[n_records].features[6]=prior_loss;
      records[n_records].features[7]=daily_seq_norm;
      records[n_records].label       =label;
      records[n_records].time         =deal_time;

      prior_loss=label;
      n_records++;
     }

   for(int s=0; s<atr_symbol_count; s++)
      if(atr_handles[s]!=INVALID_HANDLE)
         IndicatorRelease(atr_handles[s]);

   return n_records;
  }


Training, Diagnostics, and the Composite Grade

With a dataset built, the remaining functions train the network and compute the three diagnostics defined earlier, each following directly from its formula above.

TrainNetwork() and Accuracy()

TrainNetwork() sweeps the training set for a fixed number of epochs, calling CMlpNet::TrainStep() once per example per epoch. It reports the average per-example loss on the first and last epoch, so the caller can see whether training actually converged. Accuracy() applies a 0.5 decision threshold to CMlpNet::Forward() and returns the fraction of correct predictions.

//+------------------------------------------------------------------+
//| Train net on train_set for epochs full sweeps, reporting the     |
//| average per-example loss of the first and last epoch so OnStart  |
//| can show whether training actually converged.                    |
//+------------------------------------------------------------------+
void TrainNetwork(CMlpNet &net,STradeRecord &train_set[],const int n,const int epochs,
                   const double lr,double &first_loss,double &last_loss)
  {
   first_loss=0.0;
   last_loss =0.0;
   for(int epoch=0; epoch<epochs; epoch++)
     {
      double total=0.0;
      for(int k=0; k<n; k++)
         total+=net.TrainStep(train_set[k].features,train_set[k].label,lr);
      double avg=(n>0) ? total/n : 0.0;
      if(epoch==0)
         first_loss=avg;
      if(epoch==epochs-1)
         last_loss=avg;
     }
  }
//+------------------------------------------------------------------+
//| Classification accuracy at a 0.5 decision threshold.             |
//+------------------------------------------------------------------+
double Accuracy(CMlpNet &net,STradeRecord &set[],const int n)
  {
   if(n<=0)
      return 0.0;
   int correct=0;
   for(int k=0; k<n; k++)
     {
      double pred=net.Forward(set[k].features);
      int    predicted_class=(pred>=0.5) ? 1 : 0;
      if(predicted_class==(int)set[k].label)
         correct++;
     }
   return (double)correct/(double)n;
  }

CalibrationTable()

CalibrationTable() buckets every validation prediction into one of n_bins equal-width probability ranges, then computes each bin's average predicted probability and actual observed loss frequency, plus the count-weighted mean absolute error between them defined earlier.

//+------------------------------------------------------------------+
//| Bucket validation predictions into n_bins probability ranges and |
//| compare each bucket's average predicted probability against the  |
//| actual observed loss frequency in that bucket -- a direct,       |
//| honest calibration check rather than a single accuracy number.   |
//+------------------------------------------------------------------+
void CalibrationTable(CMlpNet &net,STradeRecord &val_set[],const int n,const int n_bins,
                       int &bin_count[],double &bin_avg_pred[],double &bin_actual[],double &mean_abs_err)
  {
   ArrayResize(bin_count,n_bins);
   ArrayResize(bin_avg_pred,n_bins);
   ArrayResize(bin_actual,n_bins);
   double bin_pred_sum[];
   double bin_label_sum[];
   ArrayResize(bin_pred_sum,n_bins);
   ArrayResize(bin_label_sum,n_bins);
   ArrayInitialize(bin_count,0);
   ArrayInitialize(bin_pred_sum,0.0);
   ArrayInitialize(bin_label_sum,0.0);

   for(int k=0; k<n; k++)
     {
      double p=net.Forward(val_set[k].features);
      int    b=(int)MathFloor(p*n_bins);
      if(b>=n_bins)
         b=n_bins-1;
      if(b<0)
         b=0;
      bin_count[b]++;
      bin_pred_sum[b] +=p;
      bin_label_sum[b]+=val_set[k].label;
     }

   double weighted_err=0.0;
   int    total_weight=0;
   for(int b=0; b<n_bins; b++)
     {
      if(bin_count[b]>0)
        {
         bin_avg_pred[b]=bin_pred_sum[b]/bin_count[b];
         bin_actual[b]  =bin_label_sum[b]/bin_count[b];
         weighted_err  +=MathAbs(bin_avg_pred[b]-bin_actual[b])*bin_count[b];
         total_weight  +=bin_count[b];
        }
      else
        {
         bin_avg_pred[b]=0.0;
         bin_actual[b]  =0.0;
        }
     }
   mean_abs_err=(total_weight>0) ? weighted_err/total_weight : 0.0;
  }

PermutationImportance()

PermutationImportance() processes one feature at a time. It shuffles the feature column across the validation set (Fisher-Yates), re-measures accuracy, restores the original values, and repeats this InpPermutationRepeats times. This averaging matters because a single shuffle on a modest validation set is noisy enough to rank features almost arbitrarily: one flipped prediction can swing the result by more than a full percentage point.

//+------------------------------------------------------------------+
//| Permutation feature importance: for each feature, shuffle that   |
//| column across the validation set InpPermutationRepeats times,    |
//| measure the accuracy drop each time, and average it. A feature   |
//| the network actually relies on causes a real drop when           |
//| scrambled; pure noise columns average out to roughly zero.       |
//| Averaging over several shuffles matters -- a single shuffle on a |
//| modest validation set is noisy enough to rank features almost at |
//| random.                                                          |
//+------------------------------------------------------------------+
void PermutationImportance(CMlpNet &net,STradeRecord &val_set[],const int n,const int repeats,
                            double &importances[])
  {
   ArrayResize(importances,NLPA_N_FEATURES);
   double base_acc=Accuracy(net,val_set,n);

   double orig_col[];
   double shuf_col[];
   ArrayResize(orig_col,n);
   ArrayResize(shuf_col,n);

   for(int f=0; f<NLPA_N_FEATURES; f++)
     {
      for(int k=0; k<n; k++)
         orig_col[k]=val_set[k].features[f];

      double drop_sum=0.0;
      for(int r=0; r<repeats; r++)
        {
         ArrayCopy(shuf_col,orig_col);
         for(int k=n-1; k>0; k--) // Fisher-Yates shuffle
           {
            int    j  =(int)(MathRand()%(k+1));
            double tmp=shuf_col[k];
            shuf_col[k]=shuf_col[j];
            shuf_col[j]=tmp;
           }
         for(int k=0; k<n; k++)
            val_set[k].features[f]=shuf_col[k];

         double perturbed_acc=Accuracy(net,val_set,n);
         drop_sum+=(base_acc-perturbed_acc);
        }

      for(int k=0; k<n; k++)      // restore before the next feature
         val_set[k].features[f]=orig_col[k];

      importances[f]=drop_sum/repeats;
     }
  }

CompositeGrade() and LetterGrade()

CompositeGrade() implements the three sub-score formulas and their weighted sum from the Concepts section directly. LetterGrade() maps the resulting 0–100 score to a letter using the four configurable cutoffs.

//+------------------------------------------------------------------+
//| Combine the three diagnostics into one 0-100 composite score.    |
//| Weights are normalized so the result does not depend on whether  |
//| the three Inp*Weight* inputs happen to sum to 1.                 |
//+------------------------------------------------------------------+
double CompositeGrade(const double uplift,const double calib_err,const double top_importance,
                       double &uplift_score,double &calib_score,double &feat_score)
  {
   double wsum=InpWeightUplift+InpWeightCalibration+InpWeightTopFeature;
   if(wsum<=0.0)
      wsum=1.0;
   double w_u=InpWeightUplift/wsum;
   double w_c=InpWeightCalibration/wsum;
   double w_f=InpWeightTopFeature/wsum;

   uplift_score=50.0+50.0*MathMax(-1.0,MathMin(1.0,uplift/InpUpliftScale));
   calib_score =100.0*MathMax(0.0,MathMin(1.0,1.0-(calib_err/InpCalibErrorScale)));
   feat_score  =100.0*MathMax(0.0,MathMin(1.0,top_importance/InpFeatureScale));

   return w_u*uplift_score+w_c*calib_score+w_f*feat_score;
  }
//+------------------------------------------------------------------+
//| Composite score -> letter grade using the four Inp*Grade* cuts.  |
//+------------------------------------------------------------------+
string LetterGrade(const double score)
  {
   if(score>=InpGradeA)
      return "A";
   if(score>=InpGradeB)
      return "B";
   if(score>=InpGradeC)
      return "C";
   if(score>=InpGradeD)
      return "D";
   return "F";
  }

BuildRecommendations()

BuildRecommendations() turns the numeric diagnostics into short, plain-language notes. Every condition is checked independently, so more than one recommendation can fire for the same run. A generic fallback note is returned if none of them do, so the list is never empty.

//+------------------------------------------------------------------+
//| Turn the diagnostics into a short list of plain-language notes.  |
//| Every branch is independent -- more than one can fire, and if    |
//| none does a generic fallback note is returned so the list is     |
//| never empty.                                                     |
//+------------------------------------------------------------------+
void BuildRecommendations(const double uplift,const double calib_err,const string top_name,
                           const double top_val,const int sample_n,string &recs[])
  {
   if(sample_n<InpMinTrades)
     {
      ArrayResize(recs,1);
      recs[0]="Collect more closed trades before trusting this report -- the sample is below the "+
              "configured minimum for a stable estimate.";
      return;
     }

   ArrayResize(recs,4);
   int n=0;
   if(top_val>0.02 && (top_name=="size_ratio" || top_name=="prior_loss_flag"))
     {
      recs[n]="Position size right after a losing trade is a measurable driver of your losses. Consider "+
              "a hard, mechanical size cap or a mandatory cooldown for the first trade after a loss, "+
              "then re-run this report to see whether the uplift and calibration improve.";
      n++;
     }
   if(calib_err>0.20)
     {
      recs[n]="The model's predicted probabilities are not yet well calibrated against what actually "+
              "happened. Treat the individual percentages as rough ranking signals, not precise odds, "+
              "until more closed trades accumulate.";
      n++;
     }
   if(uplift<=0.01)
     {
      recs[n]="These features do not detectably separate your winners from your losers. That is a "+
              "useful negative result: your losses may be closer to random noise than to a fixable "+
              "behavioral pattern, at least given the context captured here.";
      n++;
     }
   if(n==0)
     {
      recs[n]="No single driver stood out strongly. Review the full feature-importance ranking below "+
              "for smaller, secondary effects worth watching.";
      n++;
     }
   ArrayResize(recs,n);
  }


The Main Script: Putting It All Together

OnStart() orchestrates every piece above: it builds the dataset, checks two degenerate cases before doing any training work, splits the data chronologically, trains the network, runs all three diagnostics, and prints the report.

Two guards run before training starts. If fewer than InpMinTrades closed trades are available, or if every trade in the sample shares the same outcome (all wins or all losses, leaving no class variance for a classifier to learn from), the script prints a clear message and returns without training anything. A third guard checks that the chronological train/validation split still leaves at least 10 trades on each side. The base rate used for the accuracy-uplift comparison is the majority class measured on the training split only, never the validation split. The validation period's true rate would not be knowable in advance when the report is actually used.

//+------------------------------------------------------------------+
//| Script program start function                                    |
//+------------------------------------------------------------------+
void OnStart()
  {
   STradeRecord all_records[];
   int total_n=InpUseDemoData ? BuildDemoDataset(all_records) : BuildRealDataset(all_records);

   Print("=== Neural Loss-Pattern Auditor ===");
   if(InpUseDemoData)
      PrintFormat("Data source: built-in synthetic demo data (%d trades, seed=%d).",total_n,InpRandomSeed);
   else
      PrintFormat("Data source: real closed-deal history (%d closing deals found).",total_n);

   if(total_n<InpMinTrades)
     {
      PrintFormat("STOPPED: only %d closed trades available; the configured minimum is %d. Collect "+
                  "more history (or lower InpMinTrades, with reduced confidence) before running this again.",
                  total_n,InpMinTrades);
      return;
     }

//--- outcome-variance guard: a classifier needs both classes present
   int loss_count=0;
   for(int k=0; k<total_n; k++)
      if(all_records[k].label>=0.5)
         loss_count++;
   if(loss_count==0 || loss_count==total_n)
     {
      Print("STOPPED: every closed trade in this sample has the same outcome, so there is no class "+
            "variance to learn from. This report needs a mix of winning and losing trades.");
      return;
     }

   int split=(int)MathFloor(total_n*(1.0-InpValidationFraction));
   int val_n=total_n-split;
   if(split<10 || val_n<10)
     {
      PrintFormat("STOPPED: the train/validation split would leave fewer than 10 trades on one side "+
                  "(%d train / %d validation). Add more history or reduce InpValidationFraction.",
                  split,val_n);
      return;
     }

   STradeRecord train_set[];
   STradeRecord val_set[];
   ArrayResize(train_set,split);
   ArrayResize(val_set,val_n);
   for(int k=0; k<split; k++)
      train_set[k]=all_records[k];
   for(int k=0; k<val_n; k++)
      val_set[k]=all_records[split+k];

//--- base rate: majority class measured on the TRAINING split only -- the
//--- validation period's true rate would not be knowable in advance
   int train_loss_count=0;
   for(int k=0; k<split; k++)
      if(train_set[k].label>=0.5)
         train_loss_count++;
   int majority_class=((double)train_loss_count/split>=0.5) ? 1 : 0;

   int base_correct=0;
   for(int k=0; k<val_n; k++)
      if((int)val_set[k].label==majority_class)
         base_correct++;
   double base_acc=(double)base_correct/val_n;

   CMlpNet net;
   net.Init(NLPA_N_FEATURES,InpHiddenNeurons,InpRandomSeed);

   double first_loss,last_loss;
   TrainNetwork(net,train_set,split,InpEpochs,InpLearningRate,first_loss,last_loss);

   double val_acc=Accuracy(net,val_set,val_n);
   double uplift =val_acc-base_acc;

   int    bin_count[];
   double bin_avg_pred[];
   double bin_actual[];
   double calib_err;
   CalibrationTable(net,val_set,val_n,InpCalibrationBins,bin_count,bin_avg_pred,bin_actual,calib_err);

   double importances[];
   PermutationImportance(net,val_set,val_n,InpPermutationRepeats,importances);

   int top_idx=0;
   for(int f=1; f<NLPA_N_FEATURES; f++)
      if(importances[f]>importances[top_idx])
         top_idx=f;

   double uplift_score,calib_score,feat_score;
   double score=CompositeGrade(uplift,calib_err,MathMax(importances[top_idx],0.0),
                                uplift_score,calib_score,feat_score);
   string grade=LetterGrade(score);

   string recs[];
   BuildRecommendations(uplift,calib_err,FeatureName(top_idx),importances[top_idx],total_n,recs);

//--- report -------------------------------------------------------------
   PrintFormat("Sample: %d total closing deals -> %d training / %d validation (chronological split, "+
               "most recent %.0f%% held out).",total_n,split,val_n,InpValidationFraction*100.0);
   PrintFormat("Training loss: first epoch=%.4f, last epoch=%.4f, over %d epochs.",
               first_loss,last_loss,InpEpochs);
   PrintFormat("Validation base rate (majority class from the training split): %.2f%%",base_acc*100.0);
   PrintFormat("Validation accuracy (trained network): %.2f%%  (uplift = %+.2f percentage points)",
               val_acc*100.0,uplift*100.0);
   PrintFormat("Calibration mean absolute error across %d bins: %.4f",InpCalibrationBins,calib_err);
   for(int b=0; b<InpCalibrationBins; b++)
      PrintFormat("  bin %d: n=%d  predicted avg=%.3f  actual frequency=%.3f",
                  b,bin_count[b],bin_avg_pred[b],bin_actual[b]);
   PrintFormat("Feature importance (validation-accuracy drop when the column is shuffled, averaged "+
               "over %d repeats):",InpPermutationRepeats);
   for(int f=0; f<NLPA_N_FEATURES; f++)
      Print("  ",PadRight(FeatureName(f),16)," ",DoubleToString(importances[f],4));
   PrintFormat("Composite score: uplift=%.1f, calibration=%.1f, feature=%.1f -> TOTAL=%.1f",
               uplift_score,calib_score,feat_score,score);
   PrintFormat("BEHAVIORAL PREDICTABILITY GRADE: %s",grade);
   Print("Recommendations:");
   for(int r=0; r<ArraySize(recs); r++)
      Print("  - ",recs[r]);
  }

Running NeuralLossPatternAuditor.mq5 with its default inputs, against the built-in synthetic demo data, prints the following report to the Experts tab:

Tab Expert 2

Fig. 3. The default demo run: a modest but real accuracy uplift, imperfect calibration, and size_ratio correctly surfacing as the top feature, the same pattern injected in BuildDemoDataset().


A Worked Example: Two Trades, Two Predictions

The composite score and the feature-importance ranking are useful summaries, but it helps to see what the network actually does with two individual trades. The following pair comes directly from the held-out validation split of the default demo run shown in Fig. 3, using the trained network's own Forward() output. These are not illustrative numbers. They are what the tool computes for these exact two records.

Feature
Trade A (revenge-sized)
Trade B (normal)
prior_loss_flag
1 (previous deal was a loss)
0
size_ratio
2.62 (2.6× the recent average)
0.90 (below the recent average)
Network's predicted P(loss)
0.845
0.250
Actual outcome
Loss
Not a loss

Trade A follows a loss and is sized well past the 1.3× revenge-sizing threshold from BuildDemoDataset(). The network assigns it an 84.5% probability of being a loss, and it was one. Trade B follows a win-or-breakeven deal at a below-average size. The network assigns it a 25.0% probability of being a loss, and it was not. Neither prediction is a certainty, since the network deals in probabilities and not verdicts, but both land on the correct side of the 0.5 decision threshold. The gap between them, 84.5% against 25.0%, is exactly the kind of separation the accuracy-uplift and calibration numbers in Fig. 3 are measuring in aggregate across the full validation set.


Interpreting the Report

Read the report in the same order it prints. First, check whether the network beats the base rate at all: an uplift near or below zero, which happens for two of the five seeds discussed below, means these eight features are not detectably separating winners from losers for that data. That is itself a useful negative result. Second, check the calibration error, because even a network with a real uplift is more useful when its probabilities can be trusted at face value and not only its accuracy. Third, look at which feature tops the permutation-importance ranking, and by how much: a top feature under roughly 0.02 is close to the noise floor the pure-distractor feature sits at in the built-in demo. Only then does the composite grade and the recommendation list add much beyond what the three numbers above already say on their own. The grade exists to make several runs easy to compare at a glance, not to replace reading the diagnostics underneath it.


Applicability and Limitations

The default InpMinTrades of 60 closed trades is a floor, not a recommendation. It is the point below which OnStart() refuses to train at all, and results anywhere near that floor should be treated as provisional. Grades are also genuinely unstable on a sample this size. Running the demo generator's own logic across five different random seeds, with everything else held fixed, produced two B grades, one C grade, and two F grades. The top feature landed on size_ratio in three of the five runs and on a different feature in the other two. A tool that always reported the same confident grade regardless of the underlying data would be the more suspicious result. The spread shown here is the expected behavior of a small-sample classifier, not a flaw specific to this implementation.

The deal-level granularity described earlier has a second, subtler consequence beyond counting partial exits correctly: several closing deals from the same position share the same market context and the same prior_loss_flag value. That makes them not fully independent samples, in the way a classical statistical test would assume. This does not invalidate the report, but it is a reason to treat the exact numbers as directional rather than precise, particularly for an account with unusually frequent partial closes.

Finally, every diagnostic here describes association, not causation. A feature ranking highly in the permutation-importance report means the network's predictions depend on that feature in this historical sample. It is a reasonable hypothesis that changing the underlying behavior, for example capping size after a loss, would change future outcomes, but the report itself does not prove that causal claim. This tool complements walk-forward testing and Monte Carlo trade-reshuffling, which test a strategy's robustness to sequence and sampling directly. It does not replace either one.


Future Work

  • Aggregate features to the position level, combining partial exits by volume-weighted average or by the position's final outcome, to remove the same-position correlation noted above.
  • Add a rolling or walk-forward retraining mode that re-fits the network periodically as new history accumulates, instead of relying on a single one-shot chronological split.
  • Expose the composite score through OnTester() as a custom optimization criterion, so a strategy's revenge-sizing tendency can be optimized against directly instead of only being reported after the fact.
  • Add a second hidden layer or a wider first one once more features are added. The current single small hidden layer is deliberately sized for eight inputs, and the self-test in Module 2 is the way to re-validate a larger network before trusting it on trade data.


Conclusion

The Neural Loss-Pattern Auditor is a practical, self-contained way to test whether behavioral or market-context features in your MetaTrader 5 closed-deal history carry meaningful information about which trades are more likely to result in a loss. It trains a small neural network natively in MQL5 and, crucially, reports multiple diagnostics so you can judge whether the model's findings are trustworthy before you act on them.

What you get when you run the script:

  • A quantitative uplift in validation accuracy over the training-set majority-class baseline, a direct test of whether a signal exists at all.
  • A calibration table and mean absolute calibration error, a check on whether the predicted probabilities can be trusted as odds.
  • Permutation-based feature importances, showing which inputs the model actually relies on.
  • A heuristic composite score and letter grade for quick comparisons across runs, plus plain-language recommendations.

An uplift near or below zero is itself a useful negative result: these features do not detectably separate winners from losers on this data. Good calibration is what makes the predicted probabilities actionable as real-world odds, and a top feature well above the noise floor points to a concrete behavior worth testing directly, such as capping size after a losing trade. The limitations are worth repeating here: the tool reports associations, not causation; small samples (the script defaults to a 60-trade floor) produce unstable grades; and multiple closing deals belonging to the same position can introduce dependence between rows that makes the diagnostics look more reliable than they really are. Treat the auditor as a hypothesis tester that complements walk-forward analysis, Monte Carlo reshuffling, and conventional robustness checks, not as a standalone causal proof.

The source code is available in the MQL5 CodeBase: Neural Loss-Pattern Auditor in the MQL5 CodeBase, so you can run it on your own history, iterate on the features, and use its diagnostics to guide disciplined, testable changes to your trading behavior.

The following table describes the three files that accompany this article.

File Name
Description
NLPA_NeuralNet
Include file. Defines CMlpNet, the reusable feed-forward network engine used by both scripts below.
NLPA_NeuralNetSelfTest
Standalone script. Trains CMlpNet on a synthetic XOR-sign problem to confirm the hidden layer and backpropagation are implemented correctly.
NeuralLossPatternAuditor
Standalone script. The main tool: builds the feature dataset from demo or real history, trains the network, and prints the full diagnostic report.
MQL5.zip
An archive whose root is the MQL5 folder, so it unpacks directly into the terminal data folder with every file in its correct place. The three files sit in the MQL5\Scripts\NeuralLossPatternAuditor folder, ready to compile without moving anything.


References:
  1. MetaQuotes, "MQL5 Reference: Trade Functions," MQL5 Documentation: Trading;
  2. MetaQuotes, "MQL5 Reference: Math Functions," MQL5 Documentation: Math;
  3. Breiman, L., "Random Forests," Machine Learning, 45(1), 2001 (origin of permutation feature importance).
  4. Niculescu-Mizil, A. and Caruana, R., "Predicting Good Probabilities With Supervised Learning," ICML, 2005 (origin of the calibration approach used in this article).
  5. Neural Loss-Pattern Auditor source code, MQL5 CodeBase: Neural Loss-Pattern Auditor in the MQL5 CodeBase
Attached files |
NLPA_NeuralNet.mqh (6.13 KB)
MQL5.zip (13.06 KB)
Features of Custom Indicators Creation Features of Custom Indicators Creation
Creation of Custom Indicators in the MetaTrader trading system has a number of features.
Creating a Cairo-Inspired Graphics Library for MetaTrader 5 (Part 4): Anti-Aliasing, Coverage and Compositing Creating a Cairo-Inspired Graphics Library for MetaTrader 5 (Part 4): Anti-Aliasing, Coverage and Compositing
The rasterizer now accumulates exact span coverage in X and sampled coverage in Y, and blends it via CairoBlendOver on straight ARGB. CairoAaSamples sets the number of vertical samples at runtime, making the cost nearly linear and localized to edges. Readers get smoother boundaries, correct compositing of translucent shapes, and controllable performance.
Features of Experts Advisors Features of Experts Advisors
Creation of expert advisors in the MetaTrader trading system has a number of features.
Creating a Cairo-Inspired Graphics Library for MetaTrader 5 (Part 3): Edges and the First Filled Shape Creating a Cairo-Inspired Graphics Library for MetaTrader 5 (Part 3): Edges and the First Filled Shape
Part 3 implements a scanline rasterizer in pure MQL5. We convert path segments into top‑down edges, sweep each row, sort crossings, and apply the non‑zero winding rule to decide which pixels are inside. The result is a single Fill(path, color, buffer, w, h) routine that draws rectangles, stars, glyphs, or 2000‑edge shapes without per‑shape code, ready to integrate into your MQL5 projects.