preview
Active Learning: An EA That Only Asks for Human Confirmation on Its Most Uncertain Trades

Active Learning: An EA That Only Asks for Human Confirmation on Its Most Uncertain Trades

MetaTrader 5 — Machine learning |
148 0
Olamide Daniel Adebayo
Olamide Daniel Adebayo

Introduction

Every discretionary trader knows the setups that are easy to take, the ones that are easy to skip, and the handful in between that make you pause. This article builds a routing layer around that instinct. A small online classifier scores the next move, entropy turns the score into an uncertainty number, and the signal goes one of three ways: trade it automatically, discard it as noise, or put it in front of a human checkpoint. A human cannot click buttons inside the Strategy Tester, so the checkpoint is replaced there by mechanical proxies.

Building the router is the easy half. The harder half is finding out whether it does anything, and a backtest is easy to fool: pick a threshold on a run, report that same run, and compare against a weak benchmark. So the article follows a test protocol. The entropy ceiling is chosen on 2026.01.01 to 2026.06.30 by a rule fixed before any held-out run, then everything is judged on 2026.07.01 to 2026.09.14 against four controls: a trade-everything baseline, a matched-frequency control that thins the baseline at random to the same trade count, 50 seeded coin-flip runs, and an exact split of profit between auto-traded and queried trades.

The result is modest, and I would rather say so here. Routing loses far less than trading everything on all four symbol and timeframe legs, for example -9 647.95 for the baseline against 852.58 for confirm-all on GBPUSD M15. But on three of the four legs the routed runs' per-trade results sit inside the spread of random subsets of the baseline with the same trade count, so most of that improvement is simply trading less. The ceiling chosen in the tuning window lost money on every leg there. The pieces are worth building. The edge is not established.

Scope: this is a signal-routing and risk layer around a deliberately small classifier (price plus three indicators), not a production alpha model. All trading results come from Strategy Tester runs on MetaQuotes-Demo made on 2026.10.27, with a single data feed, zero commission and one eleven-week held-out window. The human decisions are modeled by proxies, so nothing here measures a human.

The idea comes from active learning: when labels are expensive, a model asks an annotator only about the cases it is least sure of. Replace "expensive label" with "expensive human attention" and it transfers to trading. Review everything and you start rubber-stamping; review nothing and you have surrendered control.


System Architecture

Three files split the work. ActiveConfirmLib.mqh holds the quantile estimator, the classifier, the entropy scorer and the shared enums and structs. ActiveConfirm_EA.mq5 owns the decision loop and everything that touches the trade server. ActiveConfirm_ProbView.mq5 replays the same math in a sub-window. The EA and the indicator include the same library, which carries an include guard, so they share the same formulas. The indicator's state can differ from the EA's, as the ProbView section explains.

Each new bar runs through one pipeline: build four features, score them, convert the score to uncertainty, and classify it against a fixed ceiling and a moving quantile. Fig. 1 shows where the thresholds sit on the entropy axis and Fig. 2 shows the decision path.

Fig. 1. The entropy curve and the three routing zones. With the ceiling at 0.99 only probabilities between 0.441 and 0.559 are skipped. The query threshold is a streaming quantile, so the dotted level is only an example of where it can sit.

Fig. 2. One pass of OnTick(): a new bar, the pending-query check, the scheduled refit, the score, and the three routing outcomes. The baseline run bypasses the routing step.

Global constants and defines

#ifndef ACTIVECONFIRM_LIB_MQH
#define ACTIVECONFIRM_LIB_MQH

//+------------------------------------------------------------------+
//| Global constants and defines                                     |
//+------------------------------------------------------------------+
#define FEATURE_COUNT   4        // ret1, ret5, rsi_c, atr_mom - fixed feature width
#define P2_MARKERS      5        // P2 algorithm always uses exactly 5 markers

Four features feed the classifier on purpose: a wider set overfits a 300-bar window quickly, and all I need is a cheap score. P2_MARKERS is 5 because the P² algorithm fixes it; there is nothing to tune.

ENUM_QUERY_ZONE and ENUM_HUMAN_PROXY

//+------------------------------------------------------------------+
//| ENUM_QUERY_ZONE - query-zone classification                      |
//+------------------------------------------------------------------+
enum ENUM_QUERY_ZONE
  {
   ZONE_AUTO_SKIP    = 0,        // uncertainty above the hard ceiling - discarded
   ZONE_QUERY        = 1,        // inside the review budget - ask a human
   ZONE_AUTO_TRADE   = 2,        // uncertainty below the budget quantile - execute
   ZONE_BASELINE_ALL = 3         // InpBaselineTradeAll mode - routing bypassed entirely
  };

//+------------------------------------------------------------------+
//| ENUM_HUMAN_PROXY - human proxy modes (no live click in tester)   |
//+------------------------------------------------------------------+
enum ENUM_HUMAN_PROXY
  {
   PROXY_LIVE           = 0,     // real chart buttons - live / demo only
   PROXY_RANDOM         = 1,     // coin-flip human proxy (see InpRandomSeed)
   PROXY_ALWAYS_CONFIRM = 2,     // degenerates to a plain confidence gate
   PROXY_ALWAYS_REJECT  = 3      // degenerates to skip-all-borderline
  };

The four proxy modes make tester runs possible. PROXY_LIVE is for demo and live charts. The other three answer the query on the same bar: a seeded coin flip, always confirm, and always reject. None of them is a floor or a ceiling for the result, a point the results section makes with data.

SPendingQuery and SDecisionResult

//+------------------------------------------------------------------+
//| SPendingQuery - the one query awaiting a confirm/reject answer   |
//+------------------------------------------------------------------+
struct SPendingQuery
  {
   bool             active;      // a query is currently open on chart
   datetime         signal_time; // bar time the signal was generated on
   int              direction;   // +1 buy, -1 sell
   double           probability; // classifier P(up) at signal time
   double           uncertainty; // entropy score at signal time
   double           entry_price; // price snapshot taken at signal time
   long             deadline;    // bar-counter value at which the query expires
  };

//+------------------------------------------------------------------+
//| SDecisionResult - outcome of resolving one query                 |
//+------------------------------------------------------------------+
struct SDecisionResult
  {
   bool             take_trade;  // final confirm/reject outcome
   string           tag;         // deal comment tag - "AT" or "QZ:<proxy>"
  };

SPendingQuery.deadline is a bar counter, not a datetime, since the EA already keeps g_bar_counter as its own clock and that avoids comparing timestamps across weekend gaps.


The Uncertainty Score

Uncertainty is the Shannon entropy of the classifier's predicted probability, in bits, so it runs from 0 to 1. A prediction of 0.5 scores 1.0 and a prediction near 0 or 1 scores near 0. Mathematically H(0) and H(1) are exactly 0; the code clamps p to 0.000001 to 0.999999 before taking logarithms, so the practical floor is about 0.00002 and the log is never evaluated at zero.

H(p) = −[ p · log₂(p) + (1−p) · log₂(1−p) ]

SafeSigmoid and Entropy

//+------------------------------------------------------------------+
//| SafeSigmoid - numerically stable logistic                        |
//+------------------------------------------------------------------+
double SafeSigmoid(const double z)
  {
   double zc=z;
   if(zc>35.0)
      zc=35.0;
   if(zc<-35.0)
      zc=-35.0;
   return(1.0/(1.0+MathExp(-zc)));
  }

//+------------------------------------------------------------------+
//| Entropy - normalized bit-entropy score                           |
//+------------------------------------------------------------------+
double Entropy(const double p)
  {
   double eps=0.000001;
   double pp=p;
   if(pp<eps)
      pp=eps;
   if(pp>1.0-eps)
      pp=1.0-eps;
   double h=-(pp*MathLog(pp)+(1.0-pp)*MathLog(1.0-pp))/MathLog(2.0);
   return(h);                    // ~0 near the ends (p is clamped), 1.0 at p=0.5
  }

SafeSigmoid clamps its input to plus or minus 35. That is not overflow protection, since MathExp(35) is nowhere near the limit of a double; it keeps the output a hair away from exactly 0 or 1, so the gradient and the clamped entropy stay well-behaved.

Entropy is a monotone function of the distance |p - 0.5|, so a ceiling on entropy is a ceiling on that distance. A ceiling of 0.99 skips probabilities between 0.441 and 0.559, 0.95 skips 0.369 to 0.631, 0.90 skips 0.316 to 0.684 and 0.85 skips 0.276 to 0.724. Routing on entropy and routing on |p - 0.5| therefore select the same signals, and the quantile tracker ranks them in the same order. Entropy gives a bounded scale that suits an input called a ceiling, nothing more, and this article claims no advantage of entropy over a plain probability threshold.


The Review Budget: An Online P² Quantile

The auto-skip ceiling is a fixed input. The boundary between auto-trade and ask-a-human cannot be fixed, because the entropy distribution can drift as market conditions change. What is fixed is the budget. InpReviewBudgetPct is the share of non-skipped signals that should reach the human checkpoint, and a P² streaming quantile (Jain and Chlamtac, 1985) tracks the threshold that delivers it. Skipped readings never reach the tracker, so 12 percent means 12 percent of the signals that survive the ceiling, not of all bars.

P² seeds its five markers from the first five observations and from then on nudges them with a parabolic fit, falling back to linear interpolation when the parabola would overshoot a neighbor. Buffering and sorting every value would grow without bound on a live EA, and P² avoids that.

CP2Quantile: declaration, constructor, Init

//+------------------------------------------------------------------+
//| CP2Quantile class declaration                                    |
//+------------------------------------------------------------------+
class CP2Quantile
  {
private:
   double            m_p;                       // target quantile, 0..1
   double            m_q[P2_MARKERS];           // marker heights (the estimate)
   double            m_npos[P2_MARKERS];        // desired marker positions (real)
   double            m_dn[P2_MARKERS];          // desired position increment / obs
   int               m_n[P2_MARKERS];           // actual marker positions (integer)
   double            m_init_buf[P2_MARKERS];    // buffer for the first 5 raw obs
   int               m_init_count;              // how many of the first 5 seen so far
   bool              m_ready;                   // true once markers are initialized
   //--- level 2 (private helpers, called only from Update)
   double            Parabolic(const int i,const int d);
   double            Linear(const int i,const int d);
public:
                     CP2Quantile(void);
   void              Init(const double target_quantile);
   void              Update(const double x);
   double            GetQuantile(void) const;
   bool              IsReady(void) const { return(m_ready); }
  };

//+------------------------------------------------------------------+
//| CP2Quantile: constructor                                         |
//+------------------------------------------------------------------+
CP2Quantile::CP2Quantile(void)
  {
   m_p=0.9;
   m_init_count=0;
   m_ready=false;
   ArrayInitialize(m_q,0.0);
   ArrayInitialize(m_npos,0.0);
   ArrayInitialize(m_dn,0.0);
   ArrayInitialize(m_n,0);
   ArrayInitialize(m_init_buf,0.0);
  }

//+------------------------------------------------------------------+
//| CP2Quantile: Init                                                |
//+------------------------------------------------------------------+
void CP2Quantile::Init(const double target_quantile)
  {
   m_p=target_quantile;
   m_init_count=0;
   m_ready=false;
  }

CP2Quantile::Update

//+------------------------------------------------------------------+
//| CP2Quantile: Update                                              |
//+------------------------------------------------------------------+
void CP2Quantile::Update(const double x)
  {
   //--- level 1: still filling the 5-observation seed buffer
   if(!m_ready)
     {
      m_init_buf[m_init_count]=x;
      m_init_count++;
      if(m_init_count<P2_MARKERS)
         return;
      //--- level 2: seed buffer full - sort it and set up all 5 markers
      ArraySort(m_init_buf);
      for(int i=0;i<P2_MARKERS;i++)
        {
         m_q[i]=m_init_buf[i];
         m_n[i]=i+1;
        }
      m_npos[0]=1.0;
      m_npos[1]=1.0+2.0*m_p;
      m_npos[2]=1.0+4.0*m_p;
      m_npos[3]=3.0+2.0*m_p;
      m_npos[4]=5.0;
      m_dn[0]=0.0;
      m_dn[1]=m_p/2.0;
      m_dn[2]=m_p;
      m_dn[3]=(1.0+m_p)/2.0;
      m_dn[4]=1.0;
      m_ready=true;
      return;
     }
   //--- level 1: locate the cell the new observation falls into
   int k=0;
   if(x<m_q[0])
     {
      m_q[0]=x;
      k=0;
     }
   else if(x>=m_q[4])
     {
      m_q[4]=x;
      k=3;
     }
   else
     {
      for(int i=0;i<4;i++)
        {
         if(m_q[i]<=x && x<m_q[i+1])
           {
            k=i;
            break;
           }
        }
     }
   //--- level 1: shift marker positions and desired positions
   for(int i=k+1;i<P2_MARKERS;i++)
      m_n[i]=m_n[i]+1;
   for(int i=0;i<P2_MARKERS;i++)
      m_npos[i]=m_npos[i]+m_dn[i];
   //--- level 1: adjust the three interior markers if they drifted too far
   for(int i=1;i<=3;i++)
     {
      double d=m_npos[i]-m_n[i];
      if((d>=1.0 && (m_n[i+1]-m_n[i])>1) || (d<=-1.0 && (m_n[i-1]-m_n[i])<-1))
        {
         int sd=(d>=1.0)?1:-1;
         double qnew=Parabolic(i,sd);
         //--- level 2: fall back to the linear formula if parabolic overshoots
         if(qnew>m_q[i-1] && qnew<m_q[i+1])
            m_q[i]=qnew;
         else
            m_q[i]=Linear(i,sd);
         m_n[i]=m_n[i]+sd;
        }
     }
  }

CP2Quantile::Parabolic, Linear, GetQuantile

//+------------------------------------------------------------------+
//| CP2Quantile: Parabolic                                           |
//+------------------------------------------------------------------+
double CP2Quantile::Parabolic(const int i,const int d)
  {
   double dd=(double)d;
   double term1=(m_n[i]-m_n[i-1]+dd)*(m_q[i+1]-m_q[i])/(m_n[i+1]-m_n[i]);
   double term2=(m_n[i+1]-m_n[i]-dd)*(m_q[i]-m_q[i-1])/(m_n[i]-m_n[i-1]);
   return(m_q[i]+(dd/(m_n[i+1]-m_n[i-1]))*(term1+term2));
  }

//+------------------------------------------------------------------+
//| CP2Quantile: Linear                                              |
//+------------------------------------------------------------------+
double CP2Quantile::Linear(const int i,const int d)
  {
   return(m_q[i]+d*(m_q[i+d]-m_q[i])/(m_n[i+d]-m_n[i]));
  }

//+------------------------------------------------------------------+
//| CP2Quantile: GetQuantile                                         |
//+------------------------------------------------------------------+
double CP2Quantile::GetQuantile(void) const
  {
   return(m_q[2]);                // middle marker tracks the target quantile
  }


The Classifier: Rolling Online Logistic Regression

The direction model is a logistic regression with four inputs, trained by batch gradient descent with an L2 penalty on the weights but not on the bias. It is warm-started: every InpRefitEveryBars bars, Fit() runs another InpGDIterations gradient steps on the current 300-bar window starting from the previous coefficients. Only Init() zeroes them, so the model keeps memory across refits.

p = σ(z), σ(z) = 1 / (1 + e^(-z)), z is β0 + β1·x1 + β2·x2 + β3·x3 + β4·x4

gradient step: β ← β − lr · ( (1/N)·Xᵀ(p−y) + λ·β )

Leaving the bias out of the penalty matters: penalizing it would pull the baseline prediction toward 50/50 whatever the class balance of the window.

COnlineLogReg: declaration, constructor, Init

//+------------------------------------------------------------------+
//| COnlineLogReg class declaration                                  |
//+------------------------------------------------------------------+
class COnlineLogReg
  {
private:
   double            m_beta[FEATURE_COUNT+1];   // beta[0]=bias, beta[1..N]=weights
   int               m_min_train_rows;          // rows required before IsReady()
   int               m_train_rows_seen;         // rows used on the last Fit() call
   bool              m_ready;                   // true once a real fit has run
public:
                     COnlineLogReg(void);
   void              Init(const int min_train_rows);
   bool              Fit(double &x_rows[][FEATURE_COUNT],double &y_rows[],
                          const int rows,const int iters,
                          const double lr,const double l2);
   double            Predict(double &x_row[]) const;
   bool              IsReady(void) const { return(m_ready); }
  };

//+------------------------------------------------------------------+
//| COnlineLogReg: constructor                                       |
//+------------------------------------------------------------------+
COnlineLogReg::COnlineLogReg(void)
  {
   ArrayInitialize(m_beta,0.0);
   m_min_train_rows=50;
   m_train_rows_seen=0;
   m_ready=false;
  }

//+------------------------------------------------------------------+
//| COnlineLogReg: Init                                              |
//+------------------------------------------------------------------+
void COnlineLogReg::Init(const int min_train_rows)
  {
   m_min_train_rows=min_train_rows;
   ArrayInitialize(m_beta,0.0);
   m_ready=false;
  }

COnlineLogReg::Fit and Predict

//+------------------------------------------------------------------+
//| COnlineLogReg: Fit                                               |
//+------------------------------------------------------------------+
bool COnlineLogReg::Fit(double &x_rows[][FEATURE_COUNT],double &y_rows[],
                         const int rows,const int iters,
                         const double lr,const double l2)
  {
   //--- level 1: refuse to fit on a window that is too thin to trust
   if(rows<m_min_train_rows)
     {
      m_ready=false;
      return(false);
     }
   double grad[FEATURE_COUNT+1];
   //--- level 1: batch gradient descent, L2-regularized on the weights only
   for(int it=0;it<iters;it++)
     {
      ArrayInitialize(grad,0.0);
      for(int r=0;r<rows;r++)
        {
         double z=m_beta[0];
         for(int f=0;f<FEATURE_COUNT;f++)
            z+=m_beta[f+1]*x_rows[r][f];
         double p=SafeSigmoid(z);
         double err=p-y_rows[r];
         grad[0]+=err;
         for(int f=0;f<FEATURE_COUNT;f++)
            grad[f+1]+=err*x_rows[r][f];
        }
      //--- level 2: average, add L2 on weights, take the gradient step
      grad[0]=grad[0]/rows;
      m_beta[0]=m_beta[0]-lr*grad[0];
      for(int f=0;f<FEATURE_COUNT;f++)
        {
         grad[f+1]=grad[f+1]/rows+l2*m_beta[f+1];
         m_beta[f+1]=m_beta[f+1]-lr*grad[f+1];
        }
     }
   m_train_rows_seen=rows;
   m_ready=true;
   return(true);
  }

//+------------------------------------------------------------------+
//| COnlineLogReg: Predict                                           |
//+------------------------------------------------------------------+
double COnlineLogReg::Predict(double &x_row[]) const
  {
   double z=m_beta[0];
   for(int f=0;f<FEATURE_COUNT;f++)
      z+=m_beta[f+1]*x_row[f];
   return(SafeSigmoid(z));
  }

#endif // ACTIVECONFIRM_LIB_MQH

Fit() refuses to run when the window has fewer than m_min_train_rows rows, so a cold EA waits instead of trading on a barely-trained model. Predict() uses the same SafeSigmoid as training.


Features and the Rolling Refit

All four features are price-derived: two log returns at different horizons, a centered RSI and an ATR-normalized distance from a fast SMA, clipped to plus or minus 3 ATR units and scaled. The label is plain: did price close higher InpLabelHorizonBars bars after the feature bar. The classifier's output is therefore meant to read as the probability that price closes higher, and the held-out results below are the test of whether it deserves that reading.

BuildFeatureRow (EA)

//+------------------------------------------------------------------+
//| BuildFeatureRow                                                  |
//+------------------------------------------------------------------+
bool BuildFeatureRow(const int shift,double &out_row[])
  {
   double close_arr[];
   ArraySetAsSeries(close_arr,true);
   if(CopyClose(_Symbol,_Period,0,shift+6,close_arr)<shift+6)
      return(false);
   double atr_arr[],rsi_arr[],sma_arr[];
   ArraySetAsSeries(atr_arr,true);
   ArraySetAsSeries(rsi_arr,true);
   ArraySetAsSeries(sma_arr,true);
   if(CopyBuffer(g_handle_atr,0,shift,1,atr_arr)<1)
      return(false);
   if(CopyBuffer(g_handle_rsi,0,shift,1,rsi_arr)<1)
      return(false);
   if(CopyBuffer(g_handle_sma,0,shift,1,sma_arr)<1)
      return(false);
   double atr=atr_arr[0];
   if(atr<=0.0)
      return(false);
   out_row[0]=MathLog(close_arr[shift]/close_arr[shift+1]);          // ret1
   out_row[1]=MathLog(close_arr[shift]/close_arr[shift+5]);          // ret5
   out_row[2]=(rsi_arr[0]/100.0)-0.5;                                // rsi_c
   double mom=(close_arr[shift]-sma_arr[0])/atr;
   if(mom>3.0)
      mom=3.0;
   if(mom<-3.0)
      mom=-3.0;
   out_row[3]=mom/3.0;                                               // atr_mom
   return(true);
  }

RefitModel (EA)

//+------------------------------------------------------------------+
//| RefitModel                                                       |
//+------------------------------------------------------------------+
void RefitModel(void)
  {
   int window=InpTrainWindowBars;
   int bars_needed=1+InpLabelHorizonBars+window+10;
   if(Bars(_Symbol,_Period)<bars_needed)
      return;
   double close_arr[];
   ArraySetAsSeries(close_arr,true);
   if(CopyClose(_Symbol,_Period,0,bars_needed,close_arr)<bars_needed)
      return;
   double x_rows[][FEATURE_COUNT];
   double y_rows[];
   ArrayResize(x_rows,window);
   ArrayResize(y_rows,window);
   int rows=0;
   //--- level 1: walk-forward window - feature bar i, label read off bar i-horizon
   for(int j=0;j<window;j++)
     {
      int i=1+InpLabelHorizonBars+j;
      double feat[FEATURE_COUNT];
      if(!BuildFeatureRow(i,feat))
         continue;
      int label_bar=i-InpLabelHorizonBars;
      double c_now=close_arr[i];
      double c_label=close_arr[label_bar];
      for(int f=0;f<FEATURE_COUNT;f++)
         x_rows[rows][f]=feat[f];
      y_rows[rows]=(c_label>c_now)?1.0:0.0;
      rows++;
     }
   if(rows<InpMinTrainRows)
      return;
   ArrayResize(x_rows,rows);
   ArrayResize(y_rows,rows);
   g_model.Fit(x_rows,y_rows,rows,InpGDIterations,InpLearningRate,InpL2Reg);
  }

The leakage guard is in the index arithmetic. For a training row built at offset i the label reads close_arr[i - InpLabelHorizonBars], a bar that has already closed by the time the refit runs. Mapping each j to i = 1 + InpLabelHorizonBars + j keeps every label bar inside the data available now.


The OnTick Decision Loop

OnTick() latches once per bar, resolves any open query, refits on schedule, scores the bar and routes the result. The latch uses iTime(_Symbol, _Period, 0), the bar that has just opened. The scored bar is shift 1, and scored_bar_time is what the audit log, the pending query and the calibration record all store. Orders go out on the first tick of the bar after the scored one.

Only one query can be open at a time, and while one is open the whole bar is skipped. In the tester the proxies answer on the same bar, so that never blocks anything there.

OnTick

//+------------------------------------------------------------------+
//| OnTick                                                           |
//+------------------------------------------------------------------+
void OnTick(void)
  {
   datetime bar_time=iTime(_Symbol,_Period,0);
   if(bar_time==g_last_bar_time)
      return;                    // act once per new bar, on the bar that just closed
   g_last_bar_time=bar_time;
   g_bar_counter++;
   //--- level 1: resolve or expire whatever review is already open first
   CheckPendingQuery();
   if(g_pending.active)
      return;                    // one open review at a time, by design
   //--- level 1: keep the classifier current
   if(!g_model.IsReady() || (g_bar_counter % InpRefitEveryBars)==0)
      RefitModel();
   if(!g_model.IsReady())
      return;
   //--- level 1: score the latest completed bar
   double feat[FEATURE_COUNT];
   if(!BuildFeatureRow(1,feat))
      return;
   datetime scored_bar_time=iTime(_Symbol,_Period,1);
   double p=g_model.Predict(feat);
   int direction=(p>=0.5)?1:-1;
   double u=Entropy(p);
   //--- level 1: every scored bar is recorded for the reliability check, whatever
   //--- zone it lands in; the grading happens after the run, in OnTester
   RecordCalibrationSample(scored_bar_time,p);
   //--- level 1: baseline mode bypasses all zone routing - every scored signal is
   //--- traded on sign(p-0.5) alone, the "no routing at all" control
   if(InpBaselineTradeAll)
     {
      bool base_ok=ExecuteSignal(direction,"BASELINE",g_magic_baseline);
      LogAuditRow(scored_bar_time,ZONE_BASELINE_ALL,p,u,-1.0,direction,base_ok,"BASELINE");
      return;
     }
   ENUM_QUERY_ZONE zone=ClassifyZone(u);
   double q_thresh=g_qtrack.IsReady()?g_qtrack.GetQuantile():-1.0;
   //--- level 1: route the signal
   if(zone==ZONE_AUTO_SKIP)
     {
      LogAuditRow(scored_bar_time,zone,p,u,q_thresh,direction,false,"-");
      return;
     }
   if(zone==ZONE_AUTO_TRADE)
     {
      bool ok=ExecuteSignal(direction,"AT",InpMagicNumber);
      LogAuditRow(scored_bar_time,zone,p,u,q_thresh,direction,ok,"AT");
      return;
     }
   //--- level 1: ZONE_QUERY - open a pending review and resolve it per proxy mode
   g_pending.active=true;
   g_pending.signal_time=scored_bar_time;
   g_pending.direction=direction;
   g_pending.probability=p;
   g_pending.uncertainty=u;
   g_pending.entry_price=(direction>0)?SymbolInfoDouble(_Symbol,SYMBOL_ASK):SymbolInfoDouble(_Symbol,SYMBOL_BID);
   g_pending.deadline=g_bar_counter+InpQueryTimeoutBars;
   if(InpHumanProxyMode==PROXY_LIVE)
     {
      OpenReviewButtons();
      if(InpEnablePushAlerts)
         SendNotification(StringFormat("ActiveConfirm: review requested, p=%.3f u=%.3f",p,u));
      Alert(StringFormat("ActiveConfirm: confirm or reject the %s signal on %s",
            (direction>0)?"BUY":"SELL",_Symbol));
      return;                    // wait for a click or a timeout on a later bar
     }
   //--- level 1: a proxy mode resolves the review immediately, same bar
   SDecisionResult res=ResolveProxyDecision(InpHumanProxyMode);
   bool taken=false;
   if(res.take_trade)
      taken=ExecuteSignal(g_pending.direction,res.tag,g_magic_qz);
   LogAuditRow(scored_bar_time,zone,p,u,q_thresh,direction,taken,res.tag);
   g_pending.active=false;
  }

ClassifyZone

//+------------------------------------------------------------------+
//| ClassifyZone                                                     |
//+------------------------------------------------------------------+
ENUM_QUERY_ZONE ClassifyZone(const double uncertainty)
  {
   //--- level 1: pure noise never touches the review budget
   if(uncertainty>InpAutoSkipCeiling)
      return(ZONE_AUTO_SKIP);
   g_qtrack.Update(uncertainty);
   //--- level 1: not enough history yet to trust the budget line - trade through
   if(!g_qtrack.IsReady())
      return(ZONE_AUTO_TRADE);
   if(uncertainty>=g_qtrack.GetQuantile())
      return(ZONE_QUERY);
   return(ZONE_AUTO_TRADE);
  }

Three behaviors in ClassifyZone matter for reading the results. First, g_qtrack.Update(u) runs before u is compared with the quantile, so a reading nudges the threshold it is about to be judged against. That is a coupling, not look-ahead, but it matters on legs with few signals. Second, until the tracker has seen five non-skipped readings, everything is auto-traded. Third, with InpBaselineTradeAll set, OnTick trades every scored signal on sign(p - 0.5) and returns before ClassifyZone, so the baseline cannot touch the quantile.


Human Review: Proxy Modes for Testing

On a live or demo chart PROXY_LIVE puts two buttons on the chart and waits. OnChartEvent resolves the click, and CheckPendingQuery times a query out to a no-trade after InpQueryTimeoutBars bars. None of that can run in the tester, which is why the three proxies exist.

OnChartEvent

//+------------------------------------------------------------------+
//| OnChartEvent                                                     |
//+------------------------------------------------------------------+
void OnChartEvent(const int id,const long &lparam,const double &dparam,const string &sparam)
  {
   if(id!=CHARTEVENT_OBJECT_CLICK)
      return;
   if(!g_pending.active)
      return;
   if(sparam=="ActiveConfirmBtnConfirm")
     {
      bool taken=ExecuteSignal(g_pending.direction,"QZ:LIVE_CONFIRM",g_magic_qz);
      LogAuditRow(g_pending.signal_time,ZONE_QUERY,g_pending.probability,g_pending.uncertainty,
                  g_qtrack.IsReady()?g_qtrack.GetQuantile():-1.0,
                  g_pending.direction,taken,"QZ:LIVE_CONFIRM");
      RemoveReviewButtons();
      g_pending.active=false;
     }
   else if(sparam=="ActiveConfirmBtnReject")
     {
      LogAuditRow(g_pending.signal_time,ZONE_QUERY,g_pending.probability,g_pending.uncertainty,
                  g_qtrack.IsReady()?g_qtrack.GetQuantile():-1.0,
                  g_pending.direction,false,"QZ:LIVE_REJECT");
      RemoveReviewButtons();
      g_pending.active=false;
     }
  }

OpenReviewButtons and RemoveReviewButtons

//+------------------------------------------------------------------+
//| OpenReviewButtons                                                |
//+------------------------------------------------------------------+
void OpenReviewButtons(void)
  {
   long chart=ChartID();
   ObjectCreate(chart,"ActiveConfirmBtnConfirm",OBJ_BUTTON,0,0,0);
   ObjectSetInteger(chart,"ActiveConfirmBtnConfirm",OBJPROP_XDISTANCE,20);
   ObjectSetInteger(chart,"ActiveConfirmBtnConfirm",OBJPROP_YDISTANCE,20);
   ObjectSetInteger(chart,"ActiveConfirmBtnConfirm",OBJPROP_XSIZE,90);
   ObjectSetInteger(chart,"ActiveConfirmBtnConfirm",OBJPROP_YSIZE,26);
   ObjectSetString(chart,"ActiveConfirmBtnConfirm",OBJPROP_TEXT,"Confirm");
   ObjectSetInteger(chart,"ActiveConfirmBtnConfirm",OBJPROP_BGCOLOR,clrForestGreen);
   ObjectCreate(chart,"ActiveConfirmBtnReject",OBJ_BUTTON,0,0,0);
   ObjectSetInteger(chart,"ActiveConfirmBtnReject",OBJPROP_XDISTANCE,120);
   ObjectSetInteger(chart,"ActiveConfirmBtnReject",OBJPROP_YDISTANCE,20);
   ObjectSetInteger(chart,"ActiveConfirmBtnReject",OBJPROP_XSIZE,90);
   ObjectSetInteger(chart,"ActiveConfirmBtnReject",OBJPROP_YSIZE,26);
   ObjectSetString(chart,"ActiveConfirmBtnReject",OBJPROP_TEXT,"Reject");
   ObjectSetInteger(chart,"ActiveConfirmBtnReject",OBJPROP_BGCOLOR,clrFireBrick);
   ChartRedraw(chart);
  }

//+------------------------------------------------------------------+
//| RemoveReviewButtons                                              |
//+------------------------------------------------------------------+
void RemoveReviewButtons(void)
  {
   long chart=ChartID();
   ObjectDelete(chart,"ActiveConfirmBtnConfirm");
   ObjectDelete(chart,"ActiveConfirmBtnReject");
   ChartRedraw(chart);
  }

ResolveProxyDecision and CheckPendingQuery

//+------------------------------------------------------------------+
//| ResolveProxyDecision                                             |
//+------------------------------------------------------------------+
SDecisionResult ResolveProxyDecision(const ENUM_HUMAN_PROXY mode)
  {
   SDecisionResult res;
   res.take_trade=false;
   res.tag="QZ:REJECT";
   if(mode==PROXY_RANDOM)
     {
      //--- level 2: seeded once in OnInit when InpRandomSeed is non-zero;
      //--- with the default 0 no explicit seed is set
      if((MathRand()/32767.0)<0.5)
        {
         res.take_trade=true;
         res.tag="QZ:RANDOM";
        }
      else
         res.tag="QZ:RANDOM_REJECT";
     }
   else if(mode==PROXY_ALWAYS_CONFIRM)
     {
      res.take_trade=true;
      res.tag="QZ:ALWAYS_CONFIRM";
     }
   else if(mode==PROXY_ALWAYS_REJECT)
     {
      res.take_trade=false;
      res.tag="QZ:ALWAYS_REJECT";
     }
   return(res);
  }

//+------------------------------------------------------------------+
//| CheckPendingQuery                                                |
//+------------------------------------------------------------------+
void CheckPendingQuery(void)
  {
   if(!g_pending.active)
      return;
   if(g_bar_counter<g_pending.deadline)
      return;
   //--- level 1: fail-safe default - an unanswered query times out to no trade
   LogAuditRow(g_pending.signal_time,ZONE_QUERY,g_pending.probability,g_pending.uncertainty,
               g_qtrack.IsReady()?g_qtrack.GetQuantile():-1.0,
               g_pending.direction,false,"QZ:TIMEOUT");
   RemoveReviewButtons();
   g_pending.active=false;
  }

PROXY_RANDOM draws with MathRand(). OnInit calls MathSrand(InpRandomSeed) only when the seed is non-zero, so every seeded run replays exactly. I checked that: two reruns of EURUSD H1 with seed 1 reproduced the seed-1 pass of the series to the cent (net profit -133.93, 515 trades). With seed 0 no MathSrand call is made, and I did not test whether that is reproducible, which is why the series below uses explicit seeds only.

Every trade carries a tag in its order comment: AT for auto-trade, QZ:RANDOM, QZ:ALWAYS_CONFIRM and so on for the query zone, BASELINE for the control. The tags are how the results are split afterward. What each proxy tests is different. Always-confirm shows what the entropy gate does when its query zone is accepted whole. Always-reject shows what it does with the query zone removed. A coin flip adds randomness and no information.


Trade Execution and Risk Management

Execution applies four rules I hold every order-sending EA to:

  • size by margin, never trust the risk formula alone;
  • skip the trade if the normalized lot falls under the broker's minimum, never round up into it;
  • clamp stop and take-profit distance against the real stops and freeze levels plus spread and a pad, since some symbols report those as zero;
  • clamp the lot to both the volume limit and the volume max.

ExecuteSignal also detects which filling mode the symbol supports (fill-or-kill, then immediate-or-cancel, then return) instead of hard-coding one,. The lot is sized from equity, so profit in the tester scales with the account, a fact that shapes the comparisons later.

ExecuteSignal

//+------------------------------------------------------------------+
//| ExecuteSignal                                                    |
//+------------------------------------------------------------------+
bool ExecuteSignal(const int direction,const string tag,const int magic)
  {
   double sl=0.0,tp=0.0;
   if(!CalcStops(direction,sl,tp))
      return(false);
   double price=(direction>0)?SymbolInfoDouble(_Symbol,SYMBOL_ASK):SymbolInfoDouble(_Symbol,SYMBOL_BID);
   double sl_distance=MathAbs(price-sl);
   double lot=CalcSafeLot(sl_distance,direction,price);
   if(lot<=0.0)
      return(false);
   MqlTradeRequest request;
   MqlTradeResult  result;
   ZeroMemory(request);
   ZeroMemory(result);
   request.action=TRADE_ACTION_DEAL;
   request.symbol=_Symbol;
   request.volume=lot;
   request.type=(direction>0)?ORDER_TYPE_BUY:ORDER_TYPE_SELL;
   request.price=price;
   request.sl=sl;
   request.tp=tp;
   request.deviation=10;
   request.magic=magic;
   request.comment=tag;
   //--- level 1: use a filling mode the symbol actually supports
   long fill_flags=SymbolInfoInteger(_Symbol,SYMBOL_FILLING_MODE);
   if((fill_flags & SYMBOL_FILLING_FOK)!=0)
      request.type_filling=ORDER_FILLING_FOK;
   else if((fill_flags & SYMBOL_FILLING_IOC)!=0)
      request.type_filling=ORDER_FILLING_IOC;
   else
      request.type_filling=ORDER_FILLING_RETURN;
   if(!OrderSend(request,result))
     {
      Print("ActiveConfirm: OrderSend failed, error ",GetLastError());
      return(false);
     }
   if(result.retcode!=TRADE_RETCODE_DONE && result.retcode!=TRADE_RETCODE_PLACED)
     {
      Print("ActiveConfirm: OrderSend retcode ",result.retcode," - ",result.comment);
      return(false);
     }
   return(true);
  }

CalcSafeLot

//+------------------------------------------------------------------+
//| CalcSafeLot                                                      |
//+------------------------------------------------------------------+
double CalcSafeLot(const double sl_distance,const int direction,const double price)
  {
   if(sl_distance<=0.0)
      return(0.0);
   double tick_value=SymbolInfoDouble(_Symbol,SYMBOL_TRADE_TICK_VALUE);
   double tick_size=SymbolInfoDouble(_Symbol,SYMBOL_TRADE_TICK_SIZE);
   if(tick_size<=0.0)
      return(0.0);
   double risk_money=AccountInfoDouble(ACCOUNT_EQUITY)*InpRiskPercent/100.0;
   double loss_per_lot=(sl_distance/tick_size)*tick_value;
   if(loss_per_lot<=0.0)
      return(0.0);
   double lot=risk_money/loss_per_lot;
   //--- level 1: normalize to the symbol's volume step
   double vol_step=SymbolInfoDouble(_Symbol,SYMBOL_VOLUME_STEP);
   double vol_min=SymbolInfoDouble(_Symbol,SYMBOL_VOLUME_MIN);
   double vol_max=SymbolInfoDouble(_Symbol,SYMBOL_VOLUME_MAX);
   double vol_limit=SymbolInfoDouble(_Symbol,SYMBOL_VOLUME_LIMIT);
   if(vol_step>0.0)
      lot=MathFloor(lot/vol_step)*vol_step;
   //--- level 1: rule (2) - below the broker minimum means skip, never round up
   if(lot<vol_min)
      return(0.0);
   //--- level 1: rule (4) - clamp to both max and limit, not max alone
   if(vol_max>0.0 && lot>vol_max)
      lot=vol_max;
   if(vol_limit>0.0 && lot>vol_limit)
      lot=vol_limit;
   if(vol_step>0.0)
      lot=MathFloor(lot/vol_step)*vol_step;
   if(lot<vol_min)
      return(0.0);
   //--- level 1: rule (1) - clamp by actual margin, never trust the risk formula alone
   double margin_required=0.0;
   ENUM_ORDER_TYPE ot=(direction>0)?ORDER_TYPE_BUY:ORDER_TYPE_SELL;
   if(OrderCalcMargin(ot,_Symbol,lot,price,margin_required))
     {
      double free_margin=AccountInfoDouble(ACCOUNT_MARGIN_FREE);
      if(margin_required>free_margin && margin_required>0.0)
        {
         double scale=free_margin/margin_required;
         lot=lot*scale;
         if(vol_step>0.0)
            lot=MathFloor(lot/vol_step)*vol_step;
         if(lot<vol_min)
            return(0.0);
        }
     }
   return(lot);
  }

CalcStops

//+------------------------------------------------------------------+
//| CalcStops                                                        |
//+------------------------------------------------------------------+
bool CalcStops(const int direction,double &sl,double &tp)
  {
   double atr_arr[];
   ArraySetAsSeries(atr_arr,true);
   if(CopyBuffer(g_handle_atr,0,0,1,atr_arr)<1)
      return(false);
   double atr=atr_arr[0];
   if(atr<=0.0)
      return(false);
   double point=SymbolInfoDouble(_Symbol,SYMBOL_POINT);
   double spread_points=(double)SymbolInfoInteger(_Symbol,SYMBOL_SPREAD);
   long stops_level=SymbolInfoInteger(_Symbol,SYMBOL_TRADE_STOPS_LEVEL);
   long freeze_level=SymbolInfoInteger(_Symbol,SYMBOL_TRADE_FREEZE_LEVEL);
   //--- level 1: rule (3) - never trust stops/freeze level alone, some symbols report 0
   double min_points=(double)MathMax(stops_level,freeze_level);
   double safety_pad_points=5.0;
   double min_distance=(min_points+spread_points+safety_pad_points)*point;
   double sl_distance=MathMax(InpAtrMultSL*atr,min_distance);
   double tp_distance=MathMax(InpAtrMultTP*atr,min_distance);
   double ask=SymbolInfoDouble(_Symbol,SYMBOL_ASK);
   double bid=SymbolInfoDouble(_Symbol,SYMBOL_BID);
   if(direction>0)
     {
      sl=ask-sl_distance;
      tp=ask+tp_distance;
     }
   else
     {
      sl=bid+sl_distance;
      tp=bid-tp_distance;
     }
   return(true);
  }


Measuring the Split: OnTester, the Audit Log and the Attribution

When InpWriteAuditCsv is true, every routed decision gets one row in a per-bar CSV under MQL5\Files\ActiveConfirm\. The input was false in every published run, so no result in this article depends on the CSV. The tester account in all runs was a hedging account, which matters: every signal opens its own position, and every closing deal belongs to exactly one entry order. On a netting account positions merge into one and the per-tag attribution below would not be possible.

OnTester() sums closed-deal profit by magic number and prints it to the Journal with the oracle and floor lines. Those two lines are not bounds on the final result. The oracle adds the winning query trades that the current proxy actually took, so under reject-all it collapses to the auto-trade leg, and always-confirm is not a ceiling either, since the query zone can contain losers.

OnInit

//+------------------------------------------------------------------+
//| OnInit                                                           |
//+------------------------------------------------------------------+
int OnInit(void)
  {
   //--- level 1: basic input sanity
   if(InpReviewBudgetPct<=0.0 || InpReviewBudgetPct>=100.0)
     {
      Print("ActiveConfirm: InpReviewBudgetPct must be between 0 and 100");
      return(INIT_PARAMETERS_INCORRECT);
     }
   if(InpAutoSkipCeiling<=0.0 || InpAutoSkipCeiling>1.0)
     {
      Print("ActiveConfirm: InpAutoSkipCeiling must be in (0,1]");
      return(INIT_PARAMETERS_INCORRECT);
     }
   //--- level 1: wire up the model and the budget tracker
   g_qtrack.Init(1.0-InpReviewBudgetPct/100.0);
   g_model.Init(InpMinTrainRows);
   g_pending.active=false;
   g_bar_counter=0;
   g_last_bar_time=0;
   g_magic_qz=InpMagicNumber+1;
   g_magic_baseline=InpMagicNumber+2;
   //--- level 1: explicit seed only when requested; 0 keeps the original no-seed behavior
   if(InpRandomSeed!=0)
      MathSrand(InpRandomSeed);
   //--- level 1: run stamp - when and where this pass ran
   PrintFormat("ActiveConfirm run stamp | local=%s build=%d server=%s seed=%d",
               TimeToString(TimeLocal(),TIME_DATE|TIME_SECONDS),
               (int)TerminalInfoInteger(TERMINAL_BUILD),AccountInfoString(ACCOUNT_SERVER),InpRandomSeed);
   //--- level 1: calibration sample buffer - grown in chunks by RecordCalibrationSample
   g_calib_count=0;
   ArrayResize(g_calib_time,1024);
   ArrayResize(g_calib_prob,1024);
   //--- level 1: indicator handles used by BuildFeatureRow and CalcStops
   g_handle_atr=iATR(_Symbol,_Period,InpAtrPeriod);
   g_handle_rsi=iRSI(_Symbol,_Period,InpRsiPeriod,PRICE_CLOSE);
   g_handle_sma=iMA(_Symbol,_Period,InpSmaFastPeriod,0,MODE_SMA,PRICE_CLOSE);
   if(g_handle_atr==INVALID_HANDLE || g_handle_rsi==INVALID_HANDLE || g_handle_sma==INVALID_HANDLE)
     {
      Print("ActiveConfirm: failed to create an indicator handle, error ",GetLastError());
      return(INIT_FAILED);
     }
   //--- level 1: a live proxy run inside the Strategy Tester has no one to click -
   //--- warn once so the choice isn't mistaken for a bug in the results
   if(InpHumanProxyMode==PROXY_LIVE && MQLInfoInteger(MQL_TESTER))
      Print("ActiveConfirm: PROXY_LIVE in the Strategy Tester times out every query - ",
            "use PROXY_RANDOM / PROXY_ALWAYS_CONFIRM / PROXY_ALWAYS_REJECT for real runs");
   //--- level 1: open the audit CSV, folder first per the FileOpen subfolder gotcha
   g_file_handle=INVALID_HANDLE;
   if(InpWriteAuditCsv)
     {
      //--- level 2: FolderCreate() returns true if the folder already exists too,
      //--- so a false result here is a real, worth-logging failure
      if(!FolderCreate("ActiveConfirm"))
         Print("ActiveConfirm: FolderCreate warning, error ",GetLastError());
      g_audit_path=StringFormat("ActiveConfirm\\audit_%d.csv",InpMagicNumber);
      g_file_handle=FileOpen(g_audit_path,FILE_WRITE|FILE_CSV|FILE_ANSI,',');
      if(g_file_handle==INVALID_HANDLE)
         Print("ActiveConfirm: FileOpen failed for ",g_audit_path," error ",GetLastError());
      else
         FileWrite(g_file_handle,"bar_time","zone","proxy_mode","probability",
                    "uncertainty","query_threshold","direction","taken","tag");
     }
   return(INIT_SUCCEEDED);
  }

OnDeinit

//+------------------------------------------------------------------+
//| OnDeinit                                                         |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
   RemoveReviewButtons();
   if(g_handle_atr!=INVALID_HANDLE)
      IndicatorRelease(g_handle_atr);
   if(g_handle_rsi!=INVALID_HANDLE)
      IndicatorRelease(g_handle_rsi);
   if(g_handle_sma!=INVALID_HANDLE)
      IndicatorRelease(g_handle_sma);
   if(g_file_handle!=INVALID_HANDLE)
      FileClose(g_file_handle);
  }

OnTester

//+------------------------------------------------------------------+
//| OnTester                                                         |
//+------------------------------------------------------------------+
double OnTester(void)
  {
   //--- level 1: grade every scored bar against what actually happened before
   //--- touching the PnL split - this is a read-only pass over already-closed
   //--- history, not a new decision, so it introduces no look-ahead of its own
   ComputeCalibration();
   double auto_pnl=0.0;
   double qz_pnl=0.0;
   double qz_positive_pnl=0.0;
   double baseline_pnl=0.0;
   int    qz_wins=0;
   int    qz_losses=0;
   int    baseline_trades=0;
   HistorySelect(0,TimeCurrent());
   int total=HistoryDealsTotal();
   //--- level 1: walk every closing deal this EA produced and split it by magic
   for(int i=0;i<total;i++)
     {
      ulong ticket=HistoryDealGetTicket(i);
      if(ticket==0)
         continue;
      long magic=HistoryDealGetInteger(ticket,DEAL_MAGIC);
      long entry=HistoryDealGetInteger(ticket,DEAL_ENTRY);
      if(entry!=DEAL_ENTRY_OUT)
         continue;
      double profit=HistoryDealGetDouble(ticket,DEAL_PROFIT)
                    +HistoryDealGetDouble(ticket,DEAL_SWAP)
                    +HistoryDealGetDouble(ticket,DEAL_COMMISSION);
      if(magic==InpMagicNumber)
         auto_pnl+=profit;
      else if(magic==g_magic_qz)
        {
         qz_pnl+=profit;
         if(profit>0.0)
           {
            qz_wins++;
            qz_positive_pnl+=profit;
           }
         else
            qz_losses++;
        }
      else if(magic==g_magic_baseline)
        {
         baseline_pnl+=profit;
         baseline_trades++;
        }
     }
   double oracle_pnl=auto_pnl+qz_positive_pnl;   // winners among the query trades taken
   double floor_pnl=auto_pnl;                    // auto-trade leg alone
   PrintFormat("ActiveConfirm OnTester | proxy=%d auto_pnl=%.2f qz_pnl=%.2f "
               "qz_wins=%d qz_losses=%d oracle_pnl=%.2f floor_pnl=%.2f "
               "baseline_pnl=%.2f baseline_trades=%d",
               InpHumanProxyMode,auto_pnl,qz_pnl,qz_wins,qz_losses,oracle_pnl,floor_pnl,
               baseline_pnl,baseline_trades);
   double pf=TesterStatistics(STAT_PROFIT_FACTOR);
   if(pf<=0.0 || !MathIsValidNumber(pf))
      return(auto_pnl+qz_pnl+baseline_pnl);
   return(pf);
  }

LogAuditRow

//+------------------------------------------------------------------+
//| LogAuditRow                                                      |
//+------------------------------------------------------------------+
void LogAuditRow(const datetime bar_time,const ENUM_QUERY_ZONE zone,const double probability,const double uncertainty,
                  const double q_threshold,const int direction,const bool taken,const string tag)
  {
   if(g_file_handle==INVALID_HANDLE)
      return;
   FileWrite(g_file_handle,TimeToString(bar_time,TIME_DATE|TIME_MINUTES),
             (int)zone,(int)InpHumanProxyMode,DoubleToString(probability,5),
             DoubleToString(uncertainty,5),DoubleToString(q_threshold,5),
             direction,taken?1:0,tag);
   FileFlush(g_file_handle);
  }

The split in the results section is computed independently from each saved report with report_split.py, shipped in MQL5\Files\ActiveConfirm\Python. A closing deal carries only its trigger price in the comment, so the script pairs it with the entry whose stop-loss or take-profit equals that price, requires the same volume and an earlier open time, and takes the oldest candidate when several qualify. The pairing is checked three ways: no closing deal is left unmatched, no entry is left open, and the per-tag profits add up to the report's net profit with a residual of 0.00 on all 32 reports.

The matching has one soft spot. Across the 16 held-out reports there were 298 closings with more than one candidate entry, and in only 3 of them did the candidates carry different tags. At most those 3 trades could have changed bucket, which is too few to move any conclusion below.

ComputeCalibration, called from OnTester, grades every scored bar against whether price closed higher InpLabelHorizonBars bars later and prints a five-bin reliability table and a Brier score to the Journal. RecordCalibrationSample stores each scored probability as it is produced.

RecordCalibrationSample

//+------------------------------------------------------------------+
//| RecordCalibrationSample                                          |
//+------------------------------------------------------------------+
void RecordCalibrationSample(const datetime scored_bar_time,const double probability)
  {
   //--- level 1: grow the buffer in fixed chunks rather than one element at a time -
   //--- a 9-month M15 run scores tens of thousands of bars, and resizing by 1 every
   //--- call would make this the slowest part of the whole EA
   if(g_calib_count>=ArraySize(g_calib_time))
     {
      int new_size=ArraySize(g_calib_time)+1024;
      ArrayResize(g_calib_time,new_size);
      ArrayResize(g_calib_prob,new_size);
     }
   g_calib_time[g_calib_count]=scored_bar_time;
   g_calib_prob[g_calib_count]=probability;
   g_calib_count++;
  }

ComputeCalibration

//+------------------------------------------------------------------+
//| ComputeCalibration                                               |
//+------------------------------------------------------------------+
void ComputeCalibration(void)
  {
   if(g_calib_count<1)
      return;
   //--- level 1: five equal-width probability bins, plus a running Brier-score sum
   double bin_sum_p[5],bin_sum_actual[5];
   int    bin_n[5];
   ArrayInitialize(bin_sum_p,0.0);
   ArrayInitialize(bin_sum_actual,0.0);
   ArrayInitialize(bin_n,0);
   double brier_sum=0.0;
   int    graded=0;
   int    file=INVALID_HANDLE;
   if(InpWriteAuditCsv)
     {
      string path=StringFormat("ActiveConfirm\\calibration_%d.csv",InpMagicNumber);
      file=FileOpen(path,FILE_WRITE|FILE_CSV|FILE_ANSI,',');
      if(file!=INVALID_HANDLE)
         FileWrite(file,"scored_bar_time","predicted_p","actual_up");
     }
   //--- level 1: grade every recorded sample whose label horizon has already closed -
   //--- this reuses the exact same "close InpLabelHorizonBars later" definition
   //--- RefitModel trains against, so the grade means what the model was actually fit to
   for(int i=0;i<g_calib_count;i++)
     {
      int shift_scored=iBarShift(_Symbol,_Period,g_calib_time[i],true);
      if(shift_scored<0)
         continue;                 // exact bar not found - skip rather than grade the wrong one
      int shift_outcome=shift_scored-InpLabelHorizonBars;
      if(shift_outcome<0)
         continue;                 // horizon runs past the end of available history
      double close_scored=iClose(_Symbol,_Period,shift_scored);
      double close_outcome=iClose(_Symbol,_Period,shift_outcome);
      if(close_scored<=0.0 || close_outcome<=0.0)
         continue;
      double actual=(close_outcome>close_scored)?1.0:0.0;
      double p=g_calib_prob[i];
      brier_sum+=(p-actual)*(p-actual);
      graded++;
      int bin=(int)MathFloor(p*5.0);
      if(bin>4)
         bin=4;
      if(bin<0)
         bin=0;
      bin_sum_p[bin]+=p;
      bin_sum_actual[bin]+=actual;
      bin_n[bin]++;
      if(file!=INVALID_HANDLE)
         FileWrite(file,TimeToString(g_calib_time[i],TIME_DATE|TIME_MINUTES),
                    DoubleToString(p,5),(int)actual);
     }
   if(file!=INVALID_HANDLE)
      FileClose(file);
   if(graded<1)
     {
      Print("ActiveConfirm calibration: no graded samples - horizon never closed within this run");
      return;
     }
   //--- level 1: report the reliability table and the Brier score to the Journal -
   //--- a well-calibrated model has mean_predicted close to actual_up_rate in every bin
   PrintFormat("ActiveConfirm calibration | graded_samples=%d brier_score=%.4f",
               graded,brier_sum/graded);
   for(int b=0;b<5;b++)
     {
      if(bin_n[b]<1)
        {
         PrintFormat("  bin [%.1f,%.1f): n=0",b*0.2,(b+1)*0.2);
         continue;
        }
      PrintFormat("  bin [%.1f,%.1f): n=%d mean_predicted=%.3f actual_up_rate=%.3f",
                  b*0.2,(b+1)*0.2,bin_n[b],bin_sum_p[b]/bin_n[b],bin_sum_actual[b]/bin_n[b]);
     }
  }

I ran it once per leg on the held-out window; the probabilities do not depend on the proxy mode, so one run each is enough. The outer bins are empty apart from 4 bars below 0.2 on GBPUSD M15, so the table shows the other three.

Leg Graded bars Brier score Bin 0.2 to 0.4: predicted / actual (bars) Bin 0.4 to 0.6: predicted / actual (bars) Bin 0.6 to 0.8: predicted / actual (bars)
GBPUSD M15 5 084 0.2519 0.356/0.417 (355) 0.496/0.496 (4 362) 0.636/0.587 (363)
GBPUSD H1 1 268 0.2519 0.372/0.385 (26) 0.503/0.499 (1 191) 0.639/0.549 (51)
EURUSD M15 5 084 0.2530 0.371/0.437 (309) 0.490/0.491 (4 558) 0.636/0.452 (217)
EURUSD H1 1 268 0.2499 0.381/0.390 (141) 0.495/0.509 (1 074) 0.618/0.623 (53)

The Brier scores are 0.2519, 0.2519, 0.2530 and 0.2499, against 0.25 for a forecast that always says 0.5; only EURUSD H1 is below that line, by 0.0001. On this window the classifier shows no measurable skill. Between 85 and 94 percent of bars fall in the 0.4 to 0.6 bin, where predicted and realized up-rates differ by at most 0.014. The upper bin predicted too many up-moves on three legs, most clearly on EURUSD M15 (0.636 against 0.452 on 217 bars, 5.4 standard errors). The lower bin predicted too few on the two M15 legs, by 0.06 and 0.07, about 2.3 standard errors each. Among eight tail bins some two-sigma gaps can be chance; the EURUSD M15 upper bin cannot. The probabilities are honest near 0.5 and overconfident away from it, and a forecast parked at 0.5 can be calibrated and still carry almost no information.


The ProbView Indicator

The audit CSV, when enabled, is a table rather than a picture. ActiveConfirm_ProbView plots P(up), uncertainty, the dynamic query threshold and the fixed ceiling in a sub-window, recomputed in OnCalculate so it works on any chart. A full recalculation replays the walk-forward pass statefully from the first bar with enough history.

The indicator is a visual aid and does not replay exactly what the EA traded. The EA counts bars from the start of its run and refits when that counter is a multiple of InpRefitEveryBars. The indicator refits on the absolute bar index of the chart, starts from the first bar with enough history, and reprocesses the last bar on every tick, so its quantile tracker can see one bar's reading more than once. On an arbitrary chart the plotted threshold can therefore differ from the one the EA traded against. The two agree only with an identical replay window and refit schedule. I did not synchronize them, and no result here comes from the indicator.

Indicator properties and includes

//+------------------------------------------------------------------+
//|                                       ActiveConfirm_ProbView.mq5 |
//|                       https://www.mql5.com                       |
//+------------------------------------------------------------------+
#property copyright "https://www.mql5.com"
#property link      "https://www.mql5.com"
#property version   "1.00"
#property strict
#property indicator_separate_window
#property indicator_buffers 4
#property indicator_plots   4
#property indicator_minimum 0.0
#property indicator_maximum 1.0
#property indicator_label1  "P(Up)"
#property indicator_type1   DRAW_LINE
#property indicator_color1  clrDodgerBlue
#property indicator_label2  "Uncertainty"
#property indicator_type2   DRAW_LINE
#property indicator_color2  clrOrange
#property indicator_label3  "Query threshold"
#property indicator_type3   DRAW_LINE
#property indicator_style3  STYLE_DASH
#property indicator_color3  clrSilver
#property indicator_label4  "Auto-skip ceiling"
#property indicator_type4   DRAW_LINE
#property indicator_style4  STYLE_DOT
#property indicator_color4  clrCrimson

//+------------------------------------------------------------------+
//| Includes                                                         |
//+------------------------------------------------------------------+
#include <ActiveConfirm/ActiveConfirmLib.mqh>

OnInit and OnCalculate (indicator)

//+------------------------------------------------------------------+
//| OnInit                                                           |
//+------------------------------------------------------------------+
int OnInit(void)
  {
   SetIndexBuffer(0,BufferProb,INDICATOR_DATA);
   SetIndexBuffer(1,BufferUncertainty,INDICATOR_DATA);
   SetIndexBuffer(2,BufferThreshold,INDICATOR_DATA);
   SetIndexBuffer(3,BufferCeiling,INDICATOR_DATA);
   ArraySetAsSeries(BufferProb,false);
   ArraySetAsSeries(BufferUncertainty,false);
   ArraySetAsSeries(BufferThreshold,false);
   ArraySetAsSeries(BufferCeiling,false);
   PlotIndexSetDouble(0,PLOT_EMPTY_VALUE,EMPTY_VALUE);
   PlotIndexSetDouble(1,PLOT_EMPTY_VALUE,EMPTY_VALUE);
   PlotIndexSetDouble(2,PLOT_EMPTY_VALUE,EMPTY_VALUE);
   PlotIndexSetDouble(3,PLOT_EMPTY_VALUE,EMPTY_VALUE);
   g_handle_atr=iATR(_Symbol,_Period,InpAtrPeriod);
   g_handle_rsi=iRSI(_Symbol,_Period,InpRsiPeriod,PRICE_CLOSE);
   g_handle_sma=iMA(_Symbol,_Period,InpSmaFastPeriod,0,MODE_SMA,PRICE_CLOSE);
   if(g_handle_atr==INVALID_HANDLE || g_handle_rsi==INVALID_HANDLE || g_handle_sma==INVALID_HANDLE)
     {
      Print("ActiveConfirm_ProbView: failed to create an indicator handle, error ",GetLastError());
      return(INIT_FAILED);
     }
   g_qtrack.Init(1.0-InpReviewBudgetPct/100.0);
   g_model.Init(InpMinTrainRows);
   return(INIT_SUCCEEDED);
  }

//+------------------------------------------------------------------+
//| OnCalculate                                                      |
//+------------------------------------------------------------------+
int OnCalculate(const int rates_total,const int prev_calculated,const datetime &time[],
                 const double &open[],const double &high[],const double &low[],
                 const double &close[],const long &tick_volume[],const long &volume[],
                 const int &spread[])
  {
   int warmup=InpLabelHorizonBars+6;
   if(rates_total<warmup+InpMinTrainRows)
      return(0);
   //--- level 1: a full recalculation must replay as one honest, stateful pass
   if(prev_calculated==0)
     {
      g_qtrack.Init(1.0-InpReviewBudgetPct/100.0);
      g_model.Init(InpMinTrainRows);
     }
   double atr_full[],rsi_full[],sma_full[];
   if(CopyBuffer(g_handle_atr,0,0,rates_total,atr_full)<rates_total)
      return(prev_calculated);
   if(CopyBuffer(g_handle_rsi,0,0,rates_total,rsi_full)<rates_total)
      return(prev_calculated);
   if(CopyBuffer(g_handle_sma,0,0,rates_total,sma_full)<rates_total)
      return(prev_calculated);
   int start=(prev_calculated>1)?prev_calculated-1:warmup;
   //--- level 1: walk forward bar by bar - never touch a bar past "now"
   for(int i=start;i<rates_total;i++)
     {
      if(i<warmup)
         continue;
      if((i % InpRefitEveryBars)==0 || !g_model.IsReady())
         RefitModel(i,close,atr_full,rsi_full,sma_full);
      if(!g_model.IsReady())
        {
         BufferProb[i]=EMPTY_VALUE;
         BufferUncertainty[i]=EMPTY_VALUE;
         BufferThreshold[i]=EMPTY_VALUE;
         BufferCeiling[i]=InpAutoSkipCeiling;
         continue;
        }
      double feat[FEATURE_COUNT];
      if(!BuildFeatureRow(i,close,atr_full,rsi_full,sma_full,feat))
        {
         BufferProb[i]=EMPTY_VALUE;
         BufferUncertainty[i]=EMPTY_VALUE;
         BufferThreshold[i]=EMPTY_VALUE;
         BufferCeiling[i]=InpAutoSkipCeiling;
         continue;
        }
      double p=g_model.Predict(feat);
      double u=Entropy(p);
      BufferProb[i]=p;
      BufferUncertainty[i]=u;
      BufferCeiling[i]=InpAutoSkipCeiling;
      if(u<=InpAutoSkipCeiling)
         g_qtrack.Update(u);
      BufferThreshold[i]=g_qtrack.IsReady()?g_qtrack.GetQuantile():EMPTY_VALUE;
     }
   return(rates_total);
  }

BuildFeatureRow and RefitModel (indicator)

//+------------------------------------------------------------------+
//| BuildFeatureRow                                                  |
//+------------------------------------------------------------------+
bool BuildFeatureRow(const int i,const double &close[],const double &atr_full[],
                      const double &rsi_full[],const double &sma_full[],double &out_row[])
  {
   if(i<5)
      return(false);
   double atr=atr_full[i];
   if(atr<=0.0)
      return(false);
   out_row[0]=MathLog(close[i]/close[i-1]);                 // ret1
   out_row[1]=MathLog(close[i]/close[i-5]);                 // ret5
   out_row[2]=(rsi_full[i]/100.0)-0.5;                       // rsi_c
   double mom=(close[i]-sma_full[i])/atr;
   if(mom>3.0)
      mom=3.0;
   if(mom<-3.0)
      mom=-3.0;
   out_row[3]=mom/3.0;                                       // atr_mom
   return(true);
  }

//+------------------------------------------------------------------+
//| RefitModel                                                       |
//+------------------------------------------------------------------+
void RefitModel(const int upto_i,const double &close[],const double &atr_full[],
                 const double &rsi_full[],const double &sma_full[])
  {
   int window=InpTrainWindowBars;
   int last_feat=upto_i-InpLabelHorizonBars;
   int start=last_feat-window+1;
   if(start<5)
      return;
   double x_rows[][FEATURE_COUNT];
   double y_rows[];
   ArrayResize(x_rows,window);
   ArrayResize(y_rows,window);
   int rows=0;
   //--- level 1: every training row's label bar is <= upto_i - already known
   for(int k=start;k<=last_feat;k++)
     {
      double feat[FEATURE_COUNT];
      if(!BuildFeatureRow(k,close,atr_full,rsi_full,sma_full,feat))
         continue;
      int label_bar=k+InpLabelHorizonBars;
      for(int f=0;f<FEATURE_COUNT;f++)
         x_rows[rows][f]=feat[f];
      y_rows[rows]=(close[label_bar]>close[k])?1.0:0.0;
      rows++;
     }
   if(rows<InpMinTrainRows)
      return;
   ArrayResize(x_rows,rows);
   ArrayResize(y_rows,rows);
   g_model.Fit(x_rows,y_rows,rows,InpGDIterations,InpLearningRate,InpL2Reg);
  }


Test Design

The tuning window runs from 2026.01.01 to 2026.06.30, and the ceilings 0.85, 0.90, 0.95 and 0.99 are each run once per leg with the seeded coin-flip proxy (seed 1). The selection rule is the highest profit factor among settings with at least 30 trades. It picks 0.99 on all four legs, and 0.99 also has the highest profit factor with no trade floor, so the floor did not decide anything. That ceiling is then used, unchanged, for the held-out window from 2026.07.01 to 2026.09.14, where each leg gets four runs: trade-all baseline, always-reject, always-confirm and the 50-seed coin-flip series.

Setting Value
Server and build MetaQuotes-Demo, MetaTrader 5 build 6286
Account mode Hedging demo account, 10 000 USD deposit, leverage 1:100
Tick model Every tick based on real ticks (history quality 100%), execution mode zero delay
Spread Variable, taken from the tick history; no fixed or stressed spread
Commission 0.00 on every deal; no slippage model beyond the tick feed
Symbols and timeframes GBPUSD and EURUSD; M15 and H1
Tuning window 2026.01.01 to 2026.06.30, ceilings 0.85, 0.90, 0.95, 0.99, coin-flip proxy, seed 1
Held-out window 2026.07.01 to 2026.09.14, ceiling fixed by the tuning rule, never changed afterward
Seeded series 50 passes per leg over InpRandomSeed 1 to 50, held-out window, optimization criterion set to the OnTester result
Run date 2026.10.27, every pass launched from a configuration file with the inputs embedded

The EA inputs follow. Every one is listed, including those left at their defaults.

Input Default in the source Value used in the tests
InpRefitEveryBars 50 50
InpTrainWindowBars 300 300
InpLabelHorizonBars 5 5
InpMinTrainRows 60 60
InpGDIterations 200 200
InpLearningRate 0.05 0.05
InpL2Reg 0.001 0.001
InpRsiPeriod 14 14
InpSmaFastPeriod 10 10
InpAtrPeriod 14 14
InpReviewBudgetPct 12.0 12.0
InpAutoSkipCeiling 0.90 0.85 to 0.99 while tuning; 0.99 held-out; 0.90 in the reference runs
InpHumanProxyMode PROXY_ALWAYS_CONFIRM PROXY_RANDOM, PROXY_ALWAYS_CONFIRM or PROXY_ALWAYS_REJECT per run
InpRandomSeed 0 (no MathSrand call) 1 while tuning; 1 to 50 in the series; 0 in the non-random runs
InpQueryTimeoutBars 3 3 (unused: proxies answer on the same bar)
InpEnablePushAlerts false false
InpRiskPercent 0.5 0.5
InpAtrMultSL 2.0 2.0
InpAtrMultTP 3.0 3.0
InpMagicNumber 240001 240001 (query zone 240002, baseline 240003)
InpWriteAuditCsv true false
InpBaselineTradeAll false true on the baseline runs only

Apart from the ceiling, none of these values was tuned on any window; they are the defaults I set when writing the EA, with the stop and target at 2.0 and 3.0 ATR. I did not run a sensitivity sweep on the training window, review budget, learning rate, L2 or stop and target, so the routing effect below is shown for one configuration only.

Two measurement choices. First, position size is a fraction of equity, so a run that loses early trades smaller later and a run that wins trades larger. Net profit therefore mixes trade quality with sizing. For comparing runs I use profit per lot per trade, meaning (profit + swap + commission) divided by the trade's volume, which removes the sizing. Second, the confidence intervals are 90 percent bootstrap intervals over trades (5,000 resamples, fixed seeds). Trades overlap in time, so these intervals are narrower than they should be, and I treat an interval that merely excludes zero as weak evidence.

Results: Tuning, Held-Out and the Controls

The tuning window first. Each cell shows profit factor, trades and net profit.

Leg Ceiling 0.85 Ceiling 0.90 Ceiling 0.95 Ceiling 0.99
GBPUSD M15 0.779/49/-350.50 0.626/152/-1 842.31 0.702/600/-4 487.93 0.782/3 958/-9 346.69
GBPUSD H1 0.000/4/-196.61 0.000/8/-343.62 0.314/52/-1 244.33 0.619/945/-6 800.01
EURUSD M15 0.316/52/-1 355.70 0.680/167/-1 711.87 0.903/615/-1 833.32 0.959/3 213/-4 156.12
EURUSD H1 0.369/5/-121.92 0.217/26/-772.82 0.480/198/-3 172.57 0.834/1 064/-4 900.68

Every one of the 16 settings lost money. A ceiling of 0.99 won the rule on all four legs, but only in the sense of the highest profit factor, which here still means below 1, and 0.99 is also the loosest filter: it passes roughly a third of all signals.

Now the held-out window. The baseline trades every scored signal with no routing. The matched-frequency percentile is the share of 5,000 random subsets of the baseline, each with the same number of trades as the routed run, whose mean profit per lot was lower than the routed run's. A value near 100 means the routed trades beat nearly all random thinning; a value near 50 means they are indistinguishable from it.

Leg Run Trades Net profit PF Max equity DD, % Mean P&L per lot per trade [90% CI] Matched-frequency percentile
GBPUSD M15 Trade-all baseline 4 495 -9 647.95 0.71 96.5 -16.0 -
GBPUSD M15 Reject-all 1 377 -451.97 0.99 40.1 1.4 [-5.7, 8.7] 100
GBPUSD M15 Confirm-all 1 480 852.58 1.02 36.6 3.0 [-4.3, 10.1] 100
GBPUSD H1 Trade-all baseline 1 248 -6 731.69 0.68 74.5 -36.4 -
GBPUSD H1 Reject-all 250 -2 147.29 0.70 38.0 -45.5 [-73.8, -16.7] 28
GBPUSD H1 Confirm-all 268 -2 291.80 0.70 40.2 -42.2 [-69.8, -14.9] 36
EURUSD M15 Trade-all baseline 4 040 -9 114.08 0.67 93.3 -9.4 -
EURUSD M15 Reject-all 1 191 -5 144.80 0.78 62.8 -10.8 [-16.4, -5.0] 33
EURUSD M15 Confirm-all 1 300 -5 470.07 0.78 66.1 -10.4 [-15.8, -5.2] 37
EURUSD H1 Trade-all baseline 1 137 -2 031.33 0.94 47.8 -2.4 -
EURUSD H1 Reject-all 467 -726.77 0.95 29.2 -3.9 [-23.1, 15.0] 43
EURUSD H1 Confirm-all 568 -37.96 1.00 29.8 2.0 [-14.9, 18.2] 73

Fig. 3. Held-out mean profit per lot per trade for the routed confirm-all and reject-all runs, against 5,000 random subsets of the trade-all baseline with the same number of trades as confirm-all.

Net profit says routing wins everywhere. On every leg the routed runs lose far less than the baseline, and on GBPUSD M15 confirm-all finishes positive. But net profit is the weaker comparison, because the baseline trades two to five times as often. The per-lot columns and Fig. 3 ask the sharper question, and there only GBPUSD M15 stands clear: both of its routed runs beat every one of the 5,000 random subsets. On GBPUSD H1, EURUSD M15 and EURUSD H1 the routed runs sit between the 28th and the 73rd percentile of random thinning, so the gain over the baseline there is what you get by trading fewer of the same signals.

The intervals add a second point. On GBPUSD H1 and EURUSD M15 the 90 percent interval for the routed per-lot mean lies entirely below zero, so those runs lose money per trade by any reading. On GBPUSD M15 and EURUSD H1 the intervals straddle zero. A profitable-looking run is not the same as a profitable edge: GBPUSD M15 lost 9 346.69 over the tuning window at the same ceiling (coin-flip proxy, seed 1) and made 852.58 with confirm-all on held-out data, and a swing of that size between two windows is as consistent with noise as with a change in the market.

Now exposure. The table gives mean and median holding time per closed trade, with weekends excluded as in the tester's own average holding time line. The routed medians stay within 6.2 percent of the baseline on every leg. The means stay within 6.8 percent on three legs and run about 21 to 22 percent longer on GBPUSD H1 while the median hardly moves, which points to a longer tail of slow trades. Equal trade counts therefore mean comparable exposure on three legs and only roughly so on GBPUSD H1.

Leg Baseline mean, h Baseline median, h Reject-all mean, h Reject-all median, h Confirm-all mean, h Confirm-all median, h
GBPUSD M15 5.1 2.6 5.4 2.7 5.3 2.7
GBPUSD H1 16.0 11.0 19.5 11.7 19.3 11.1
EURUSD M15 5.5 2.8 5.1 2.8 5.2 2.8
EURUSD H1 16.7 9.8 17.7 9.5 17.7 9.6


The Query Zone Against the Auto-Trade Zone

The question the active-learning idea hinges on is whether the uncertain slice behaves differently from the confident slice. Under always-confirm both slices trade, so their profit can be split exactly. The table splits each confirm-all report with report_split.py, and the auto plus query columns add up to the report's net profit on every leg.

Leg Auto trades Auto PnL Auto per lot Query trades Query PnL Query per lot Query minus auto, per lot [90% CI] One-sided permutation p
GBPUSD M15 1331 437.28 2.3 149 415.30 9.0 +6.7 [-15.6, 28.8] 0.33
GBPUSD H1 249 -2 105.28 -45.0 19 -186.52 -5.7 +39.4 [-71.1, 158.3] 0.27
EURUSD M15 1172 -4 852.20 -10.6 128 -617.87 -8.9 +1.6 [-14.8, 18.8] 0.43
EURUSD H1 456 -741.49 -2.5 112 703.53 20.4 +22.8 [-16.9, 64.1] 0.19

The query zone does better per lot than the auto zone on all four legs, but no interval excludes zero and the one-sided permutation p-values run from 0.19 to 0.43. Four positive signs in a row has a one-in-sixteen chance if each leg were a coin flip, and the legs share a calendar and a data feed, so they are not independent draws. Treat it as a lead to test on more data. The query zone is small, between 19 and 149 trades per leg, which is why the intervals are wide.


What a Coin-Flip Proxy Can and Cannot Show

A single coin-flip run is one draw from a distribution. Fifty seeded draws per leg show how wide it is, and Fig. 4 puts confirm-all and reject-all on the same axis. One unseeded pass per leg is left out, because an unseeded draw cannot be replayed from this article.

Leg Mean net [90% CI of the mean] Median Std dev Min to max Seeds above zero Median PF Trades per run Seeds at or above confirm-all / reject-all
GBPUSD M15 -245.5 [-361.9, -133.5] -245.1 493.4 -2 007.8 to 819.5 10 of 50 0.994 1417 to 1460 0/40
GBPUSD H1 -2 190.8 [-2 211.7, -2 169.6] -2 181.7 91.9 -2 348.0 to -1 975.3 0 of 50 0.705 255 to 263 41/15
EURUSD M15 -5 268.8 [-5 301.6, -5 237.9] -5 251.6 137.3 -5 740.2 to -5 062.4 0 of 50 0.781 1233 to 1261 47/10
EURUSD H1 -337.4 [-421.2, -253.6] -361.1 363.3 -1 120.8 to 608.9 9 of 50 0.977 506 to 527 10/44

Fig. 4. Net profit of the 50 seeded coin-flip runs on each leg, with the confirm-all and reject-all results marked on the same axis.

The spread is large on the M15 GBPUSD leg: net profit ranges from -2 007.8 to 819.5, with 10 of 50 seeds finishing positive, and the mean is -245.5. On GBPUSD M15 confirm-all beats all 50 seeds,. On the other legs confirm-all and reject-all sit inside the cloud: on EURUSD H1, 44 of 50 coin-flip seeds beat always-reject, and on EURUSD M15, 47 of 50 beat always-confirm. So a coin flip is neither a floor nor a ceiling for the result, and the seed cloud is the right reference for any claim about what a checkpoint adds.


The 0.90 Reference and What the Numbers Say

For transparency, here are the held-out runs at 0.90, the EA's default ceiling. The rule did not select it, it lost on every leg in tuning, and the cells are small, so treat it as a reference only.

Leg Confirm-all trades Net PF Reject-all trades Net PF
GBPUSD M15 103 860.65 1.29 90 596.97 1.23
GBPUSD H1 7 -340.65 0.00 6 -289.36 0.00
EURUSD M15 37 1 041.14 2.37 26 566.66 1.99
EURUSD H1 10 -3.80 0.99 8 -143.42 0.50

The two M15 cells look attractive, but they have 26 to 103 trades each and come from a setting that lost in the window it was tuned on. The H1 cells have 6 to 10 trades and say nothing. In short:

  • Routing beats trade-everything on net profit and drawdown on all four legs, though mostly by trading less.
  • Beyond trading less, entropy selection is supported on one leg out of four, GBPUSD M15, and that leg lost in tuning.
  • The query zone beats the auto zone on all four legs by a margin no interval confirms.
  • The ceiling that won tuning produced a profit under only one proxy on one leg.
  • The held-out Brier scores sit at 0.25, the level of a constant 0.5 forecast, so the classifier shows no measurable skill.

Limits that remain: commission is zero and the spread comes from the tick feed, so there is no cost test, which matters most on M15; the held-out window is eleven weeks on one feed; the H1 legs have 250 to 570 trades against roughly 1,200 to 1,500 on M15; I compared four legs and several statistics, so some intervals will look better than they are; the classifier and risk settings were not swept; and the interval method treats overlapping trades as independent.


Conclusion

The routing layer works as software: it scores, skips, queries and trades, every decision is tagged, and the profit split is exact on hedging accounts. As a trading idea it has not yet shown an edge. The checkpoint's value stays open, and a human proxy that is a coin flip cannot answer it.

The part worth copying is the protocol: choose the ceiling on one window by a written rule, judge it on another, compare against matched-frequency and seeded controls, and split profit exactly instead of by hand. The next steps are more symbols and periods, repeated held-out windows, a real cost model with commission and a stressed spread, a sensitivity check on the training window, review budget, learning rate, L2 and the stop and target, and a human-in-the-loop run on a demo account, where PROXY_LIVE is the mode to use.

Programs used in the article

# Name Type Description
1 ActiveConfirm_EA.mq5 Expert Advisor The EA: scores each closed bar, routes the signal to skip, query or auto-trade, executes, splits profit by entry tag in OnTester and writes the audit log.
2 ActiveConfirmLib.mqh Include file Shared enums and structs, the entropy scorer, the P² streaming quantile tracker and the warm-started online logistic regression.
3 ActiveConfirm_ProbView.mq5 Indicator Sub-window indicator that replays P(up), uncertainty, the query threshold and the skip ceiling on the chart.
4 report_split.py Python script Splits a saved tester report's profit by entry tag and prints the residual against the report's net profit. Stored in MQL5\Files\ActiveConfirm\Python\.
5 MQL5.zip Archive All files above with the standard folder layout, ready to unzip into the terminal data folder.

Unzip MQL5.zip into the terminal's data folder (File > Open Data Folder) and compile the EA and the indicator. The Python script is optional: run python report_split.py report.htm on a saved tester report and it prints the profit split by entry tag.

Attached files |
MQL5.zip (16 KB)
A Categorical Hidden Markov Model in MQL5 A Categorical Hidden Markov Model in MQL5
The article provides a detailed examination of the theoretical foundations and practical implementation of the hidden Markov model with categorical emissions (Categorical HMM) in the MQL5 language. Specific examples are used to demonstrate the processes of inference, iterative parameter learning, and online filtering, as well as the methodology for selecting the optimal model architecture based on the AIC/BIC information criteria.
Efficient Volatility Estimation with Garman–Klass: From Theory to a Percentile-Based Trading Tool in MQL5 Efficient Volatility Estimation with Garman–Klass: From Theory to a Percentile-Based Trading Tool in MQL5
This article implements the Garman–Klass volatility estimator as an MQL5 indicator that uses OHLC data for higher statistical efficiency than close-to-close measures. A percentile-based squeeze/expansion detector produces breakout signals, and a companion Expert Advisor executes them via iCustom. Implementation details include series-aware indexing, recursive squeeze-state handling in indicator buffers, and a baseline-on-attach rule for the EA, giving a ready-to-use toolkit for volatility-regime trading.
MetaTrader 5 Machine Learning Blueprint (Part 26): Process Permutation MetaTrader 5 Machine Learning Blueprint (Part 26): Process Permutation
We introduce the permutation process for backtest validation: permute bars, rerun the complete development process, and compare the observed Sharpe to the null distribution it produces. Outputs include one-sided p-values, robust z, and quantile confidence intervals, plus a spurious performance curve that quantifies selection versus trial budget. A cache-isolation check prevents false passes. The EURUSD H1 case shows how to set a tolerance and assess whether performance is distinguishable from selection.
How to Build a 29-Pair Portfolio with an L1 Filter and VaR-based Allocation of Lots How to Build a 29-Pair Portfolio with an L1 Filter and VaR-based Allocation of Lots
The article explores the practical application of the L1 Trend Filter for noise reduction and the construction of structural features suitable for live trading. The full workflow is presented: H1 data for 29 instruments from MetaTrader 5, causal filtering, CatBoost with a horizon of three L1 bars, rigorous walk-forward testing, and VaR-based allocation of lots. The reader is provided with a reproducible code pipeline and a portfolio evaluation methodology.