preview
Weak Supervision for Traders: Combining Several Noisy Labeling Heuristics into One Probabilistic Label (Snorkel-Style)

Weak Supervision for Traders: Combining Several Noisy Labeling Heuristics into One Probabilistic Label (Snorkel-Style)

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

Introduction

Every rule-based signal you have ever written is a noisy vote, not a ground truth. An RSI crossback, a moving-average cross, a MACD flip—each one fires on a real pattern, but each one is also wrong a meaningful fraction of the time, and none of them agree with each other as often as you would like. The usual fix is to pick your favorite one, or to AND a couple of them together and call it a filter. Both approaches throw away information: a favorite signal ignores the others entirely, and an AND-gate treats every input as equally trustworthy, which none of them are - and it also throws away every bar where the favorite signal abstains but two others agree strongly.

This article builds something different: a small generative model in the style of Stanford's Snorkel. It takes five independent, cheap, noisy technical heuristics (none accurate enough to trade alone) and learns how much to trust each one purely from their agreement patterns on live bars. No labeled training set, no manually assigned confidence weights, no grid search over LF weights. Expectation-Maximization does the weighting dynamically as market conditions change, and it does it from the same kind of evidence a human discretionary trader uses when deciding which of their indicators to believe this week: not "was this indicator right," which nobody can know in real time, but "does this indicator keep siding with what the others are converging on."

I built this as an MQL5 Expert Advisor with an EM fitting engine in a shared include library. I wired it to five MetaTrader indicator-backed heuristics and ran it across six Strategy Tester configurations (three symbols × two timeframes), alongside an unweighted majority-vote baseline. The results are reported exactly as produced by the terminal. This includes the one configuration where EM's per-LF weighting did not help, and the fact that none of the twelve runs turned a profit. The interesting finding in this article isn't "this system makes money" - it doesn't, as shipped. It's that a model with zero access to trade outcomes can still estimate, from vote agreement alone, which of five noisy heuristics deserves more say in the fused decision, and that trusting it over an unweighted vote changes the resulting P&L - on this test matrix mostly by losing a little less, though the statistical check in the results section shows that difference is too small to separate from noise.

This is a labeling and signal-fusion technique, not a strategy with an edge baked in. The five labeling functions here are illustrative - RSI, an EMA cross, Bollinger reversion, a MACD flip, and a candle proxy - chosen because they are common, cheap, and genuinely uncorrelated enough to make the fusion problem real. Swap in your own heuristics; the EM engine and the trust-weighting idea are what this article is actually about.



The idea in one picture

Each of the five labeling functions (LFs) looks at the closed bar and returns one of three things: a buy vote, a sell vote, or an abstain, when it has no opinion. Those five votes go into CWeakLabelModel, which has learned - purely from historical agreement patterns - how accurate each LF tends to be, and folds that into a single fused probability of the next move being a buy.


Fig. 1. Signal-fusion pipeline: five labeling functions vote, the EM label model weighs them by learned reliability, and the result is a single P(Buy).

The model that does the fusing has two kinds of parameters: alpha_j , one per labeling function, the model's own estimate - under the latent-variable assumptions, with no ground truth to check it against - of how often LF j's vote agrees with the inferred (not observed) label when it doesn't abstain, and a single class prior pi , the model's running estimate of how often "buy" is the right call in the current regime. Both are found with Expectation-Maximization, run purely on the LF vote history - never on price outcomes, never on a hand-labeled dataset. This is the core trick borrowed from Snorkel's data-programming line of work: you can recover a useful estimate of each labeling source's reliability from nothing but a matrix of votes, provided the sources are not all wrong in the same correlated way at once.

E-step: q_i = P(Y=+1 | votes_i) via log-sum-exp over
  log(pi) + sum_j log(alpha_j) or log(1 - alpha_j), by vote agreement

M-step: pi = mean(q_i) over the buffered window
  alpha_j = weighted_correct_j / weighted_total_j, weighted by q_i

That is the entire mechanism, and it is genuinely that short - the whole EM loop is under forty lines of MQL5, shown in full in Section 4. An LF whose votes consistently land on the side the posterior favors gets a high alpha_j and more say in future fusions; an LF that is basically coin-flipping against the consensus gets clamped down toward the floor. There's no separate training phase - the model refits on a rolling window every InpRefitEveryBars bars, so its opinion of each LF drifts as market conditions change, without you ever telling it what "changed." Section 10 shows exactly what got learned across six real markets, and it is not the same ranking twice.


The five labeling functions

CLabelingFunctions owns five native MetaTrader indicator handles and exposes one Vote* method per heuristic, plus a wrapper that collects all five votes for one bar in a single call. Every method reads the CLOSED bar at the given shift and never repaints - shift 0 is never touched from OnTick, and every buffer read is guarded with a CopyBuffer() length check that returns an abstain rather than a garbage value if history isn't ready yet.

Vote constants and the model-wide #define block

//--- level 1: model-wide constants
#define WS_NUM_LF          5        // number of labeling functions
#define WS_VOTE_SELL      -1        // labeling-function / label vote: sell
#define WS_VOTE_ABSTAIN    0        // labeling-function / label vote: abstain
#define WS_VOTE_BUY        1        // labeling-function / label vote: buy
#define WS_EM_MAX_ITERS    50       // EM iteration cap
#define WS_EM_MIN_ITERS    5        // EM iterations before convergence check
#define WS_EM_TOL          0.0001   // EM log-likelihood convergence tolerance
#define WS_ALPHA_FLOOR     0.501    // accuracy floor (must beat a coin flip)
#define WS_ALPHA_CEIL      0.999    // accuracy ceiling (avoid log(0) blow-up)
#define WS_MIN_ROWS_TO_FIT 60       // minimum buffered bars before first EM fit

The abstain value matters more than it looks - it's what lets the model treat "this LF had nothing to say on this bar" differently from "this LF said sell." A naive scheme that forces every LF to vote on every bar would corrupt the EM fit, because a forced vote on a bar the LF genuinely can't read is noise dressed up as evidence. Keeping abstain as a real third state is what makes the vote-agreement statistics actually mean something.

CLabelingFunctions - class declaration

//+------------------------------------------------------------------+
//| CLabelingFunctions                                               |
//| Five independent, cheap, noisy technical heuristics. Each Vote*  |
//| method returns WS_VOTE_SELL / WS_VOTE_ABSTAIN / WS_VOTE_BUY for  |
//| the CLOSED bar at the given shift - never repaints.              |
//+------------------------------------------------------------------+
class CLabelingFunctions
  {
private:
   string            m_symbol;      // symbol this instance reads
   ENUM_TIMEFRAMES   m_tf;          // timeframe this instance reads
   int               m_h_rsi;       // RSI handle, LF1
   int               m_h_ma_fast;   // fast EMA handle, LF2
   int               m_h_ma_slow;   // slow EMA handle, LF2
   int               m_h_bb;        // Bollinger Bands handle, LF3
   int               m_h_macd;      // MACD handle, LF4

public:
                     CLabelingFunctions(void);
                    ~CLabelingFunctions(void);
   bool              Init(const string symbol,const ENUM_TIMEFRAMES tf,
                           const int rsi_period,const int ma_fast_period,
                           const int ma_slow_period,const int bb_period,
                           const double bb_deviation,const int macd_fast,
                           const int macd_slow,const int macd_signal);
   void              Deinit(void);
   int               VoteRsiExtreme(const int shift);
   int               VoteMaCross(const int shift);
   int               VoteBollingerReversion(const int shift);
   int               VoteMacdFlip(const int shift);
   int               VoteCandleProxy(const int shift);
   void              GetAllVotes(const int shift,int &votes[]);
  };

CLabelingFunctions::Init - creates the five native indicator handles

//+------------------------------------------------------------------+
//| Create the native indicator handles the five LFs read from       |
//+------------------------------------------------------------------+
bool CLabelingFunctions::Init(const string symbol,const ENUM_TIMEFRAMES tf,
                               const int rsi_period,const int ma_fast_period,
                               const int ma_slow_period,const int bb_period,
                               const double bb_deviation,const int macd_fast,
                               const int macd_slow,const int macd_signal)
  {
   m_symbol = symbol;
   m_tf     = tf;

   m_h_rsi     = iRSI(m_symbol,m_tf,rsi_period,PRICE_CLOSE);
   m_h_ma_fast = iMA(m_symbol,m_tf,ma_fast_period,0,MODE_EMA,PRICE_CLOSE);
   m_h_ma_slow = iMA(m_symbol,m_tf,ma_slow_period,0,MODE_EMA,PRICE_CLOSE);
   m_h_bb      = iBands(m_symbol,m_tf,bb_period,0,bb_deviation,PRICE_CLOSE);
   m_h_macd    = iMACD(m_symbol,m_tf,macd_fast,macd_slow,macd_signal,PRICE_CLOSE);

   if(m_h_rsi==INVALID_HANDLE || m_h_ma_fast==INVALID_HANDLE ||
      m_h_ma_slow==INVALID_HANDLE || m_h_bb==INVALID_HANDLE ||
      m_h_macd==INVALID_HANDLE)
     {
      Print("CLabelingFunctions::Init failed, GetLastError=",GetLastError());
      return(false);
     }
   return(true);
  }

LF1 fires on an RSI crossback through the classic 30/70 extremes - not on the extreme touch itself, but on the reversal back through it, which is the part that actually correlates with a turn. Touching 28 and staying there is not a signal here; crossing back above 30 after having been below it is:

CLabelingFunctions::VoteRsiExtreme

//+------------------------------------------------------------------+
//| LF1: RSI oversold/overbought crossback                           |
//| Buy vote  - RSI crosses back above 30 from below (reversal up)   |
//| Sell vote - RSI crosses back below 70 from above (reversal down) |
//+------------------------------------------------------------------+
int CLabelingFunctions::VoteRsiExtreme(const int shift)
  {
   double rsi_now[],rsi_prev[];
   if(CopyBuffer(m_h_rsi,0,shift,1,rsi_now) != 1)   return(WS_VOTE_ABSTAIN);
   if(CopyBuffer(m_h_rsi,0,shift+1,1,rsi_prev) != 1) return(WS_VOTE_ABSTAIN);

   if(rsi_prev[0] < 30.0 && rsi_now[0] >= 30.0)
      return(WS_VOTE_BUY);
   if(rsi_prev[0] > 70.0 && rsi_now[0] <= 70.0)
      return(WS_VOTE_SELL);
   return(WS_VOTE_ABSTAIN);
  }

LF2 is a short-lookback fast/slow EMA cross, deliberately built from different periods than the EA's own longer trend filter (Section 6) so the two never just repeat the same signal under different names - if they used the same lookback, EM would effectively be double-counting one underlying feature and inflating its apparent reliability:

CLabelingFunctions::VoteMaCross

//+------------------------------------------------------------------+
//| LF2: fast/slow EMA cross (short-lookback, distinct periods from  |
//| the EA's own longer-period trend filter)                         |
//+------------------------------------------------------------------+
int CLabelingFunctions::VoteMaCross(const int shift)
  {
   double fast_now[],fast_prev[],slow_now[],slow_prev[];
   if(CopyBuffer(m_h_ma_fast,0,shift,1,fast_now)   != 1) return(WS_VOTE_ABSTAIN);
   if(CopyBuffer(m_h_ma_fast,0,shift+1,1,fast_prev) != 1) return(WS_VOTE_ABSTAIN);
   if(CopyBuffer(m_h_ma_slow,0,shift,1,slow_now)   != 1) return(WS_VOTE_ABSTAIN);
   if(CopyBuffer(m_h_ma_slow,0,shift+1,1,slow_prev) != 1) return(WS_VOTE_ABSTAIN);

   bool crossed_up   = (fast_prev[0] <= slow_prev[0]) && (fast_now[0] > slow_now[0]);
   bool crossed_down = (fast_prev[0] >= slow_prev[0]) && (fast_now[0] < slow_now[0]);

   if(crossed_up)   return(WS_VOTE_BUY);
   if(crossed_down) return(WS_VOTE_SELL);
   return(WS_VOTE_ABSTAIN);
  }

LF3 votes on a Bollinger Band touch-and-reversion: the prior bar has to actually pierce the band, and this bar has to close back inside it. A bar that merely brushes the band without piercing it, or pierces it and keeps going, gets no vote - this is a reversion signal, and it should only fire on an actual reversion, not on volatility expansion in the same direction:

CLabelingFunctions::VoteBollingerReversion

//+------------------------------------------------------------------+
//| LF3: Bollinger Band touch + reversion                            |
//| Buy vote  - prior bar pierced the lower band, this bar closes    |
//|             back inside it                                       |
//| Sell vote - mirror image at the upper band                       |
//+------------------------------------------------------------------+
int CLabelingFunctions::VoteBollingerReversion(const int shift)
  {
   double upper_now[],lower_now[],upper_prev[],lower_prev[];
   double close_now[],close_prev[],low_prev[],high_prev[];
   if(CopyBuffer(m_h_bb,1,shift,1,upper_now)        != 1) return(WS_VOTE_ABSTAIN);
   if(CopyBuffer(m_h_bb,2,shift,1,lower_now)        != 1) return(WS_VOTE_ABSTAIN);
   if(CopyBuffer(m_h_bb,1,shift+1,1,upper_prev)   != 1) return(WS_VOTE_ABSTAIN);
   if(CopyBuffer(m_h_bb,2,shift+1,1,lower_prev)   != 1) return(WS_VOTE_ABSTAIN);
   if(CopyClose(m_symbol,m_tf,shift,1,close_now)   != 1) return(WS_VOTE_ABSTAIN);
   if(CopyClose(m_symbol,m_tf,shift+1,1,close_prev) != 1) return(WS_VOTE_ABSTAIN);
   if(CopyLow(m_symbol,m_tf,shift+1,1,low_prev)    != 1) return(WS_VOTE_ABSTAIN);
   if(CopyHigh(m_symbol,m_tf,shift+1,1,high_prev)  != 1) return(WS_VOTE_ABSTAIN);

   //--- level 2: the prior bar pierces ITS OWN band; the current bar's
   //    return is checked against the CURRENT bar's band, not the stale one
   bool pierced_lower = (low_prev[0]  < lower_prev[0]) && (close_prev[0] < lower_prev[0]);
   bool pierced_upper = (high_prev[0] > upper_prev[0]) && (close_prev[0] > upper_prev[0]);

   if(pierced_lower && close_now[0] >= lower_now[0])
      return(WS_VOTE_BUY);
   if(pierced_upper && close_now[0] <= upper_now[0])
      return(WS_VOTE_SELL);
   return(WS_VOTE_ABSTAIN);
  }

LF4 is the simplest of the five - a MACD histogram sign flip, main minus signal crossing zero. It's included specifically because it's a momentum signal rather than a reversion one, which gives the ensemble a genuinely different failure mode than LF1 and LF3, both of which are mean-reversion in spirit:

CLabelingFunctions::VoteMacdFlip

//+------------------------------------------------------------------+
//| LF4: MACD histogram sign flip                                    |
//+------------------------------------------------------------------+
int CLabelingFunctions::VoteMacdFlip(const int shift)
  {
   double main_now[],main_prev[],signal_now[],signal_prev[];
   if(CopyBuffer(m_h_macd,0,shift,1,main_now)     != 1) return(WS_VOTE_ABSTAIN);
   if(CopyBuffer(m_h_macd,0,shift+1,1,main_prev)   != 1) return(WS_VOTE_ABSTAIN);
   if(CopyBuffer(m_h_macd,1,shift,1,signal_now)   != 1) return(WS_VOTE_ABSTAIN);
   if(CopyBuffer(m_h_macd,1,shift+1,1,signal_prev) != 1) return(WS_VOTE_ABSTAIN);

   double hist_now  = main_now[0]  - signal_now[0];
   double hist_prev = main_prev[0] - signal_prev[0];

   if(hist_prev <= 0.0 && hist_now > 0.0) return(WS_VOTE_BUY);
   if(hist_prev >= 0.0 && hist_now < 0.0) return(WS_VOTE_SELL);
   return(WS_VOTE_ABSTAIN);
  }

LF5 is a candle-pattern proxy that catches both an engulfing bar and a pin-bar off the same body-to-range ratio, rather than shipping two separate LFs for what is really one idea - a small body dominated by one wick, or a full reversal of the prior bar's body. The 30% body-fraction cutoff for the pin-bar case and the 2x wick-to-body ratio were picked to keep this LF selective rather than chatty; a looser threshold fires on almost every bar and stops being informative:

CLabelingFunctions::VoteCandleProxy

//+------------------------------------------------------------------+
//| LF5: candle-pattern proxy (engulfing / pin-bar via body:range)   |
//| Buy vote  - bullish engulfing OR a bullish pin-bar (long lower   |
//|             wick, small body near the top of the range)          |
//| Sell vote - mirror bearish conditions                            |
//+------------------------------------------------------------------+
int CLabelingFunctions::VoteCandleProxy(const int shift)
  {
   double o0[],c0[],h0[],l0[],o1[],c1[];
   if(CopyOpen(m_symbol,m_tf,shift,1,o0)    != 1) return(WS_VOTE_ABSTAIN);
   if(CopyClose(m_symbol,m_tf,shift,1,c0)   != 1) return(WS_VOTE_ABSTAIN);
   if(CopyHigh(m_symbol,m_tf,shift,1,h0)    != 1) return(WS_VOTE_ABSTAIN);
   if(CopyLow(m_symbol,m_tf,shift,1,l0)     != 1) return(WS_VOTE_ABSTAIN);
   if(CopyOpen(m_symbol,m_tf,shift+1,1,o1)  != 1) return(WS_VOTE_ABSTAIN);
   if(CopyClose(m_symbol,m_tf,shift+1,1,c1) != 1) return(WS_VOTE_ABSTAIN);

   double range = h0[0] - l0[0];
   if(range <= 0.0)
      return(WS_VOTE_ABSTAIN);

   double body      = MathAbs(c0[0] - o0[0]);
   double body_frac = body / range;
   double upper_wick = h0[0] - MathMax(o0[0],c0[0]);
   double lower_wick = MathMin(o0[0],c0[0]) - l0[0];

   //--- level 2: bullish/bearish engulfing of the previous bar's body
   bool bull_engulf = (c1[0] < o1[0]) && (c0[0] > o0[0]) &&
                       (c0[0] >= o1[0]) && (o0[0] <= c1[0]);
   bool bear_engulf = (c1[0] > o1[0]) && (c0[0] < o0[0]) &&
                       (o0[0] >= c1[0]) && (c0[0] <= o1[0]);

   //--- level 2: pin-bar proxy, small body, dominant opposing wick
   bool bull_pin = (body_frac < 0.30) && (lower_wick > 2.0 * body) &&
                    (lower_wick > upper_wick);
   bool bear_pin = (body_frac < 0.30) && (upper_wick > 2.0 * body) &&
                    (upper_wick > lower_wick);

   if(bull_engulf || bull_pin) return(WS_VOTE_BUY);
   if(bear_engulf || bear_pin) return(WS_VOTE_SELL);
   return(WS_VOTE_ABSTAIN);
  }

And the convenience wrapper the EA actually calls once per bar, in a fixed index order that the audit CSV and both figures in Section 10 rely on - index 0 is always RSI, 1 is always the MA cross, and so on:

CLabelingFunctions::GetAllVotes

//+------------------------------------------------------------------+
//| Convenience wrapper: all five LF votes for one bar in one call   |
//+------------------------------------------------------------------+
void CLabelingFunctions::GetAllVotes(const int shift,int &votes[])
  {
   ArrayResize(votes,WS_NUM_LF);
   votes[0] = VoteRsiExtreme(shift);
   votes[1] = VoteMaCross(shift);
   votes[2] = VoteBollingerReversion(shift);
   votes[3] = VoteMacdFlip(shift);
   votes[4] = VoteCandleProxy(shift);
  }


The generative label model

CWeakLabelModel is the part doing the actual weak-supervision work. It keeps a fixed-capacity ring buffer of past vote rows, a learned accuracy per LF, and a learned class prior, and exposes Fit() to re-run EM and Predict() to fuse a fresh vote row once fitted.

SLFStats - per-labeling-function learned statistics

//+------------------------------------------------------------------+
//| Per-labeling-function learned statistics                         |
//+------------------------------------------------------------------+
struct SLFStats
  {
   double            accuracy;        // learned P(vote == Y | vote != 0)
   int               coverage;        // non-abstain votes seen in the fit window
   int               agree_with_map;  // non-abstain votes agreeing with final MAP label
  };

CWeakLabelModel - class declaration

//+------------------------------------------------------------------+
//| CWeakLabelModel                                                  |
//| Snorkel-style generative label model: learns each labeling       |
//| function's accuracy and the class prior from LF                  |
//| agreement/disagreement patterns alone (no ground-truth labels),  |
//| via EM, then fuses new LF vote vectors into a single P(Y=+1)     |
//| estimate.                                                        |
//+------------------------------------------------------------------+
class CWeakLabelModel
  {
private:
   int               m_capacity;            // rolling-window row capacity
   int               m_count;               // rows currently buffered
   int               m_head;                // next write index (ring buffer)
   int               m_votes[][WS_NUM_LF];  // buffered LF vote rows
   double            m_prior;               // P(Y=+1), learned
   SLFStats          m_stats[WS_NUM_LF];    // learned per-LF stats
   bool              m_is_fitted;           // true once EM has converged once

   double            Clamp(const double x,const double lo,const double hi);
   double            PosteriorBuy(const int &row[]);
   double            LogLikelihood(void);

public:
                     CWeakLabelModel(void);
                    ~CWeakLabelModel(void);
   void              Init(const int capacity);
   void              PushVoteRow(const int &votes[]);
   bool              Fit(void);
   double            Predict(const int &votes[]);
   bool              IsFitted(void) const { return(m_is_fitted); }
   double            GetAccuracy(const int lf_index) const;
   int               GetCoverage(const int lf_index) const;
   int               BufferedRows(void) const { return(m_count); }
   double            GetPrior(void) const { return(m_prior); }
  };

CWeakLabelModel::CWeakLabelModel - constructor, seed values

//+------------------------------------------------------------------+
//| Constructor                                                      |
//+------------------------------------------------------------------+
CWeakLabelModel::CWeakLabelModel(void)
  {
   m_capacity  = 0;
   m_count     = 0;
   m_head      = 0;
   m_prior     = 0.5;
   m_is_fitted = false;
   for(int j=0; j<WS_NUM_LF; j++)
     {
      m_stats[j].accuracy       = 0.6;   // seed value, Fit() overwrites it
      m_stats[j].coverage       = 0;
      m_stats[j].agree_with_map = 0;
     }
  }

The 0.6 seed on each alpha_j is a starting point, not a belief - it only matters for the handful of E-steps before the first M-step corrects it, and EM is not sensitive to it in practice here since the vote-agreement signal in a real rolling window of 500+ bars dominates almost immediately. Init() allocates the ring buffer once the EA knows its configured window size, and PushVoteRow() writes into it every bar, wrapping the head index once the buffer is full - this is a genuine ring buffer, not an ever-growing array, so memory and per-bar cost stay flat no matter how long the EA has been running:

CWeakLabelModel::Init

//+------------------------------------------------------------------+
//| Allocate the rolling-window vote buffer                          |
//+------------------------------------------------------------------+
void CWeakLabelModel::Init(const int capacity)
  {
   m_capacity = MathMax(capacity,WS_MIN_ROWS_TO_FIT);
   ArrayResize(m_votes,m_capacity);
   m_count = 0;
   m_head  = 0;
  }

CWeakLabelModel::PushVoteRow

//+------------------------------------------------------------------+
//| Append one bar's LF vote vector into the ring buffer             |
//+------------------------------------------------------------------+
void CWeakLabelModel::PushVoteRow(const int &votes[])
  {
   for(int j=0; j<WS_NUM_LF; j++)
      m_votes[m_head][j] = votes[j];

   m_head = (m_head + 1) % m_capacity;
   if(m_count < m_capacity)
      m_count++;
  }

The core of the fusion is PosteriorBuy(): given one vote row and the current alpha/prior estimates, it computes the log-posterior for buy and sell under a naive-Bayes-style independence assumption across LFs, then normalizes back to a probability. Working in log-space here isn't a style choice - multiplying five or more probability terms directly underflows to zero in double precision faster than you'd expect, especially once alpha_j is close to its 0.999 ceiling and several LFs agree strongly. Summing logs and only exponentiating once, at the very end, via a log-sum-exp with the max subtracted out first, keeps every intermediate value in a numerically sane range:

CWeakLabelModel::Clamp

//+------------------------------------------------------------------+
//| Clamp a value into [lo, hi]                                      |
//+------------------------------------------------------------------+
double CWeakLabelModel::Clamp(const double x,const double lo,const double hi)
  {
   if(x < lo) return(lo);
   if(x > hi) return(hi);
   return(x);
  }

CWeakLabelModel::PosteriorBuy

//+------------------------------------------------------------------+
//| P(Y=+1 | vote row) under the current alpha_j / prior estimate    |
//+------------------------------------------------------------------+
double CWeakLabelModel::PosteriorBuy(const int &row[])
  {
   double log_buy  = MathLog(Clamp(m_prior,0.001,0.999));
   double log_sell = MathLog(Clamp(1.0 - m_prior,0.001,0.999));

   for(int j=0; j<WS_NUM_LF; j++)
     {
      if(row[j] == WS_VOTE_ABSTAIN)
         continue;                                    // abstain, no evidence

      double a = m_stats[j].accuracy;
      if(row[j] == WS_VOTE_BUY)
        {
         log_buy  += MathLog(a);
         log_sell += MathLog(1.0 - a);
        }
      else // WS_VOTE_SELL
        {
         log_buy  += MathLog(1.0 - a);
         log_sell += MathLog(a);
        }
     }

   //--- level 2: log-sum-exp normalization back to a probability
   double m        = MathMax(log_buy,log_sell);
   double buy_exp  = MathExp(log_buy  - m);
   double sell_exp = MathExp(log_sell - m);
   return(buy_exp / (buy_exp + sell_exp));
  }

LogLikelihood() exists purely to drive the EM convergence check - for every buffered row it computes the raw log-likelihood under each hypothesis (buy/sell) from alpha_j and the prior, then log-sum-exps the two into that row's true marginal log P(row), and sums across the window. That is the actual mixture-density log-likelihood, not a stand-in built from the already-normalized posterior, so the standard EM monotonicity guarantee genuinely applies: each E-step/M-step pair cannot decrease it, and watching it stop moving is a mathematically sound stopping rule, not a heuristic guess at "probably converged by now":

CWeakLabelModel::LogLikelihood

//+------------------------------------------------------------------+
//| Total data log-likelihood under the current parameters           |
//+------------------------------------------------------------------+
double CWeakLabelModel::LogLikelihood(void)
  {
   double total = 0.0;
   int    row[WS_NUM_LF];

   for(int i=0; i<m_count; i++)
     {
      for(int j=0; j<WS_NUM_LF; j++)
         row[j] = m_votes[i][j];

      //--- level 2: raw log P(row|Y=+1) / log P(row|Y=-1) - NOT the
      //    posterior, which already folds the prior in and would
      //    double-count it here
      double log_buy  = MathLog(Clamp(m_prior,0.001,0.999));
      double log_sell = MathLog(Clamp(1.0 - m_prior,0.001,0.999));

      for(int j=0; j<WS_NUM_LF; j++)
        {
         if(row[j] == WS_VOTE_ABSTAIN)
            continue;

         double a = m_stats[j].accuracy;
         if(row[j] == WS_VOTE_BUY)
           {
            log_buy  += MathLog(a);
            log_sell += MathLog(1.0 - a);
           }
         else // WS_VOTE_SELL
           {
            log_buy  += MathLog(1.0 - a);
            log_sell += MathLog(a);
           }
        }

      //--- level 2: log-sum-exp of the two mixture terms - this IS
      //    log P(row_i), the quantity standard EM monotonicity covers
      double m_hi = MathMax(log_buy,log_sell);
      total += m_hi + MathLog(MathExp(log_buy - m_hi) + MathExp(log_sell - m_hi));
     }
   return(total);
  }

And Fit() ties it together: alternate an E-step (posterior per row, using PosteriorBuy()) and an M-step (re-estimate the prior, then each LF's accuracy, weighted by that posterior) until the log-likelihood stops moving by more than WS_EM_TOL , capped at WS_EM_MAX_ITERS iterations, with a floor of WS_EM_MIN_ITERS so it never declares convergence on the first pass before the parameters have had a real chance to move:

CWeakLabelModel::Fit - the EM loop

//+------------------------------------------------------------------+
//| Run EM to convergence over the buffered rolling window           |
//| E-step: soft posterior P(Y=+1|votes_i) from current alpha_j/pi   |
//| M-step: re-estimate pi and each alpha_j from that posterior      |
//+------------------------------------------------------------------+
bool CWeakLabelModel::Fit(void)
  {
   if(m_count < WS_MIN_ROWS_TO_FIT)
      return(false);

   double q[];                                        // per-row soft posterior
   ArrayResize(q,m_count);
   double prev_ll = -DBL_MAX;

   for(int iter=0; iter<WS_EM_MAX_ITERS; iter++)
     {
      int row[WS_NUM_LF];

      //--- level 2: E-step, one posterior per buffered row
      for(int i=0; i<m_count; i++)
        {
         for(int j=0; j<WS_NUM_LF; j++)
            row[j] = m_votes[i][j];
         q[i] = PosteriorBuy(row);
        }

      //--- level 2: M-step, prior first
      double sum_q = 0.0;
      for(int i=0; i<m_count; i++)
         sum_q += q[i];
      m_prior = Clamp(sum_q / m_count,0.02,0.98);

      //--- level 2: M-step, per-LF accuracy from the soft posterior
      for(int j=0; j<WS_NUM_LF; j++)
        {
         double weighted_correct = 0.0;
         double weighted_total   = 0.0;
         for(int i=0; i<m_count; i++)
           {
            int v = m_votes[i][j];
            if(v == WS_VOTE_ABSTAIN)
               continue;
            double credit = (v == WS_VOTE_BUY) ? q[i] : (1.0 - q[i]);
            weighted_correct += credit;
            weighted_total   += 1.0;
           }
         if(weighted_total > 0.0)
            m_stats[j].accuracy = Clamp(weighted_correct / weighted_total,
                                         WS_ALPHA_FLOOR,WS_ALPHA_CEIL);
         m_stats[j].coverage = (int)weighted_total;
        }

      //--- level 2: convergence check on data log-likelihood
      double ll = LogLikelihood();
      if(iter >= WS_EM_MIN_ITERS && MathAbs(ll - prev_ll) < WS_EM_TOL)
        {
         prev_ll = ll;
         break;
        }
      prev_ll = ll;
     }

   //--- level 2: agreement-with-MAP bookkeeping, surfaced for the audit CSV
   int row2[WS_NUM_LF];
   for(int j=0; j<WS_NUM_LF; j++)
      m_stats[j].agree_with_map = 0;

   for(int i=0; i<m_count; i++)
     {
      for(int j=0; j<WS_NUM_LF; j++)
         row2[j] = m_votes[i][j];
      double p_buy   = PosteriorBuy(row2);
      int    map_lbl = (p_buy >= 0.5) ? WS_VOTE_BUY : WS_VOTE_SELL;
      for(int j=0; j<WS_NUM_LF; j++)
         if(row2[j] == map_lbl)
            m_stats[j].agree_with_map++;
     }

   m_is_fitted = true;
   return(true);
  }

Notice what Fit() never touches: price, P&L, or any outcome label. It only ever looks at how the five vote columns agree and disagree with each other across the buffered window. An LF that regularly sides with the emerging consensus earns a high alpha_j . One that is essentially noise is clamped toward WS_ALPHA_FLOOR (0.501). The floor assumes adversarial LFs are rare and prevents MathLog() domain errors near zero. In real runs on the rolling windows used in this article, convergence typically lands well inside the 50-iteration cap - the log-likelihood tends to stop moving meaningfully within 10-15 passes once the window has enough non-abstain votes to work with, which is worth knowing if you're tuning InpRollingWindowBars for a tighter refit cadence on your own instrument.

Once fitted, fusing a fresh vote row is a single call - this is the only method the EA calls on every single tick after warm-up, and it's deliberately cheap: no loop over the buffer, just five log-additions and one log-sum-exp:

CWeakLabelModel::Predict

//+------------------------------------------------------------------+
//| Fuse a fresh LF vote vector into P(Y=+1) using learned params    |
//+------------------------------------------------------------------+
double CWeakLabelModel::Predict(const int &votes[])
  {
   if(!m_is_fitted)
      return(0.5);                                     // neutral until first fit
   return(PosteriorBuy(votes));
  }

CWeakLabelModel::GetAccuracy / GetCoverage - diagnostics accessors

//+------------------------------------------------------------------+
//| Accessor: learned accuracy for one labeling function             |
//+------------------------------------------------------------------+
double CWeakLabelModel::GetAccuracy(const int lf_index) const
  {
   if(lf_index < 0 || lf_index >= WS_NUM_LF)
      return(0.5);
   return(m_stats[lf_index].accuracy);
  }

//+------------------------------------------------------------------+
//| Accessor: non-abstain vote count for one labeling function        |
//+------------------------------------------------------------------+
int CWeakLabelModel::GetCoverage(const int lf_index) const
  {
   if(lf_index < 0 || lf_index >= WS_NUM_LF)
      return(0);
   return(m_stats[lf_index].coverage);
  }

GetCoverage() matters as much as GetAccuracy() for reading the results honestly - a learned alpha_j near the ceiling on a handful of non-abstain votes is a much weaker claim than the same number on hundreds of them, and Section 10's Journal output reports both side by side for exactly that reason.


Majority vote: the baseline this article measures EM against

A weighted model is only interesting if it beats the thing it replaces. The honest baseline here isn't "no filter" - that wouldn't isolate what EM is actually contributing. It's the same five LFs combined the naive way, an unweighted majority vote where a reliable LF and a coin-flipping one carry identical influence:

WsMajorityVoteProbability

//+------------------------------------------------------------------+
//| Unweighted majority-vote baseline - the Snorkel-less alternative |
//| every LF counts equally regardless of how reliable it is         |
//+------------------------------------------------------------------+
double WsMajorityVoteProbability(const int &votes[])
  {
   int buy = 0, sell = 0;
   for(int j = 0; j < WS_NUM_LF; j++)
     {
      if(votes[j] == WS_VOTE_BUY)  buy++;
      if(votes[j] == WS_VOTE_SELL) sell++;
     }

   int total = buy + sell;
   if(total == 0)
      return(0.5);                                   // all five abstained

   return((double)buy / (double)total);
  }

This function counts buy votes and sell votes among the non-abstaining LFs and returns the buy share directly as a probability - no accuracy weighting, no prior, no log-space anything. It's about as close to "the simplest thing that could plausibly work" as a fusion rule gets, which is exactly what makes it a fair yardstick: any margin EM shows over this baseline is coming from the learned weighting, not from some other confound.

The EA exposes the choice between the two as a single input toggle, so the exact same rolling window, refit cadence, trend gate, and risk rules run under both modes - the only thing that changes is which function computes p_buy :

Weak Label Model input group - InpUseMajorityVoteBaseline is the switch

//--- level 1: weak label model inputs
input group "Weak Label Model";
input int      InpRollingWindowBars       = 500;   // EM fit window, bars
input int      InpRefitEveryBars          = 200;   // refit cadence, bars
input double   InpConfidenceThreshold     = 0.65;  // P(Y=+1) gate for a buy/sell
input bool     InpUseMajorityVoteBaseline = false; // true = plain vote count, no EM

And in OnTick(), the branch is a single ternary - both paths still gate on g_model.IsFitted() so the warm-up period is identical either way, which matters for a fair comparison. Without that shared gate, the baseline could start trading earlier than EM simply because it needs no fitted model, and any resulting profit difference would be measuring warm-up timing rather than fusion quality:

   double p_buy = InpUseMajorityVoteBaseline ? WsMajorityVoteProbability(votes)
                                              : g_model.Predict(votes);


Risk management and order execution

None of the weak-supervision logic above matters if the EA can size a position wrong. Three helper functions in the same library carry a few non-negotiable risk rules for any order-sending EA - real margin checks via the terminal's own margin calculator rather than a hand-rolled approximation, a real minimum-stop-distance floor that accounts for a broker reporting zero on stops/freeze level, and clamping against both a broker's volume limit and its volume max, not just one of the two, since clamping to max alone can still leave a lot size above a broker's separate volume-limit cap on some symbols.

WsCalcSafeLot - margin- and broker-limit-clamped lot sizing

//+------------------------------------------------------------------+
//| Compute a margin- and broker-limit-clamped lot size              |
//| Returns 0.0 if the risk-based lot cannot be honored at all       |
//+------------------------------------------------------------------+
double WsCalcSafeLot(const string symbol,const double risk_money,
                      const double sl_distance_price,const ENUM_ORDER_TYPE order_type)
  {
   if(sl_distance_price <= 0.0)
      return(0.0);

   double tick_value = SymbolInfoDouble(symbol,SYMBOL_TRADE_TICK_VALUE);
   double tick_size  = SymbolInfoDouble(symbol,SYMBOL_TRADE_TICK_SIZE);
   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(tick_value <= 0.0 || tick_size <= 0.0 || vol_step <= 0.0)
      return(0.0);

   //--- level 2: risk-formula lot, before any clamping
   double loss_per_lot = (sl_distance_price / tick_size) * tick_value;
   if(loss_per_lot <= 0.0)
      return(0.0);
   double raw_lot = risk_money / loss_per_lot;

   //--- level 2: normalize to the broker's volume step
   double lot = MathFloor(raw_lot / vol_step) * vol_step;

   //--- level 2: rule (2) - below broker minimum means skip, never round up
   if(lot < vol_min)
      return(0.0);

   //--- level 2: rule (4) - clamp to BOTH volume limit and volume max
   if(vol_max   > 0.0) lot = MathMin(lot,vol_max);
   if(vol_limit > 0.0) lot = MathMin(lot,vol_limit);

   //--- level 2: rule (1) - clamp by real account margin via OrderCalcMargin
   double required_margin = 0.0;
   double price = (order_type == ORDER_TYPE_BUY)
                  ? SymbolInfoDouble(symbol,SYMBOL_ASK)
                  : SymbolInfoDouble(symbol,SYMBOL_BID);

   if(OrderCalcMargin(order_type,symbol,lot,price,required_margin))
     {
      double free_margin = AccountInfoDouble(ACCOUNT_MARGIN_FREE);
      while(lot > 0.0 && required_margin > free_margin)
        {
         lot -= vol_step;
         lot  = MathFloor(lot / vol_step) * vol_step;
         if(lot < vol_min)
           {
            lot = 0.0;
            break;
           }
         if(!OrderCalcMargin(order_type,symbol,lot,price,required_margin))
           {
            lot = 0.0;
            break;
           }
        }
     }
   else
     {
      //--- level 2: the FIRST OrderCalcMargin call failed outright -
      //    never fall through with an un-margin-checked lot
      Print("WsCalcSafeLot(",symbol,") OrderCalcMargin err=",GetLastError());
      return(0.0);
     }

   return(lot);
  }

The margin check here isn't a one-shot calculation - if the risk-formula lot would require more margin than the account actually has free, the function steps the lot size down by one volume step at a time, re-checking OrderCalcMargin() each time, until either it fits or the lot bottoms out below the broker's minimum, at which point the trade is skipped entirely (see rule 2 below) rather than force-opened at an undersized, arbitrarily-clamped size.

WsMinStopDistance - broker-safe minimum SL/TP distance

//+------------------------------------------------------------------+
//| Enforce a broker-safe minimum SL/TP distance in price terms      |
//| Rule (3): MAX(stops level, freeze level) + spread + safety pad   |
//+------------------------------------------------------------------+
double WsMinStopDistance(const string symbol,const double safety_pad_points)
  {
   long   stops_level_pts  = SymbolInfoInteger(symbol,SYMBOL_TRADE_STOPS_LEVEL);
   long   freeze_level_pts = SymbolInfoInteger(symbol,SYMBOL_TRADE_FREEZE_LEVEL);
   double point            = SymbolInfoDouble(symbol,SYMBOL_POINT);
   double spread_pts       = (double)SymbolInfoInteger(symbol,SYMBOL_SPREAD);

   double base_pts = MathMax((double)stops_level_pts,(double)freeze_level_pts);
   double total_pts = base_pts + spread_pts + safety_pad_points;

   return(total_pts * point);
  }

The audit CSV writes into its own named subfolder under MQL5\Files , and FileOpen() does not create a missing subfolder on its own - it fails silently and returns INVALID_HANDLE if the folder isn't already there, with nothing in the Journal to flag it unless the caller explicitly checks and prints GetLastError(). WsEnsureFilesFolder() exists purely to call FolderCreate() once before the first FileOpen(), and ignores "already exists" errors:

WsEnsureFilesFolder

//+------------------------------------------------------------------+
//| Create the named audit-log subfolder before the first FileOpen   |
//| (FileOpen does not create missing MQL5\Files subfolders itself)  |
//+------------------------------------------------------------------+
bool WsEnsureFilesFolder(const string subfolder_name)
  {
   if(!FolderCreate(subfolder_name))
     {
      int err = GetLastError();
      if(err != 5019 && err != 5018 && err != 0)     // 5019/5018 = already exists
         Print("WsEnsureFilesFolder(",subfolder_name,") err=",err);
     }
   return(true);
  }

The other two input groups configure the LF indicator periods and the base trend/trade-management layer - the trend filter and ATR-based stops are intentionally simple, since the point of this article is the fusion layer above them, not a novel exit scheme:

Labeling Functions input group

//--- level 1: labeling-function indicator inputs
input group "Labeling Functions";
input int      InpRsiPeriod        = 14;      // LF1: RSI period
input int      InpLfMaFastPeriod   = 10;      // LF2: fast EMA period
input int      InpLfMaSlowPeriod   = 30;      // LF2: slow EMA period
input int      InpBbPeriod         = 20;      // LF3: Bollinger period
input double   InpBbDeviation      = 2.0;     // LF3: Bollinger deviation
input int      InpMacdFast         = 12;      // LF4: MACD fast EMA
input int      InpMacdSlow         = 26;      // LF4: MACD slow EMA
input int      InpMacdSignal       = 9;       // LF4: MACD signal SMA

Trend Filter and Trade Management input group

//--- level 1: base trend filter and trade management inputs
input group "Trend Filter and Trade Management";
input int      InpTrendMaPeriod    = 200;     // long EMA, direction gate
input int      InpAtrPeriod        = 14;      // ATR period for SL/TP
input double   InpAtrSlMultiplier  = 2.0;     // SL distance = ATR * this
input double   InpAtrTpMultiplier  = 3.0;     // TP distance = ATR * this
input double   InpRiskPercent      = 1.0;     // percent of equity risked per trade
input double   InpSafetyPadPoints  = 10.0;    // extra pad beyond stops/freeze level
input int      InpMaxSpreadPoints  = 50;      // skip new entries above this spread
input ulong    InpMagic            = 990101;  // magic number

EA globals

//--- level 1: globals
CTrade               g_trade;                                               // order wrapper
CLabelingFunctions   g_lf;                                                  // the 5 LFs
CWeakLabelModel      g_model;                                               // EM label model
int                  g_h_trend_ma      = INVALID_HANDLE;                    // trend EMA handle
int                  g_h_atr           = INVALID_HANDLE;                    // ATR handle
datetime             g_last_bar_time   = 0;                                 // new-bar tracker
int                  g_bars_since_fit  = 0;                                 // refit cadence counter
int                  g_audit_handle    = INVALID_HANDLE;                    // audit CSV handle
string               g_audit_subfolder = "WeakSupervision";                 // MQL5\Files subfolder
string               g_audit_filename  = "WeakSupervision\\audit_log.csv";  // audit CSV path

TryOpen() is where sizing and stops are computed and a market order is sent, applying WsMinStopDistance() and WsCalcSafeLot() before ever touching CTrade. If the safe lot comes back at zero - meaning the risk-based size rounds below the broker's minimum tradeable volume - the function logs that outcome to the audit CSV and returns without sending anything, rather than silently skipping or, worse, rounding up to a size the risk budget never approved:

TryOpen

//+------------------------------------------------------------------+
//| Size, clamp and send one market order per the standing EA rules  |
//+------------------------------------------------------------------+
void TryOpen(const ENUM_ORDER_TYPE order_type,const double p_buy)
  {
   double atr[];
   if(CopyBuffer(g_h_atr,0,1,1,atr) != 1 || atr[0] <= 0.0)
      return;

   //--- level 2: rule (3), enforce the broker-safe minimum stop distance
   double min_stop_dist = WsMinStopDistance(_Symbol,InpSafetyPadPoints);
   double sl_dist = MathMax(atr[0] * InpAtrSlMultiplier,min_stop_dist);
   double tp_dist = MathMax(atr[0] * InpAtrTpMultiplier,min_stop_dist);

   double equity      = AccountInfoDouble(ACCOUNT_EQUITY);
   double risk_money  = equity * (InpRiskPercent / 100.0);
   double lot = WsCalcSafeLot(_Symbol,risk_money,sl_dist,order_type);

   if(lot <= 0.0)
     {
      LogAuditRow(p_buy,"skip_lot_zero");
      return;
     }

   double price = (order_type == ORDER_TYPE_BUY)
                  ? SymbolInfoDouble(_Symbol,SYMBOL_ASK)
                  : SymbolInfoDouble(_Symbol,SYMBOL_BID);
   double sl = (order_type == ORDER_TYPE_BUY) ? price - sl_dist : price + sl_dist;
   double tp = (order_type == ORDER_TYPE_BUY) ? price + tp_dist : price - tp_dist;

   bool sent = (order_type == ORDER_TYPE_BUY)
               ? g_trade.Buy(lot,_Symbol,price,sl,tp,"WeakSupervision")
               : g_trade.Sell(lot,_Symbol,price,sl,tp,"WeakSupervision");

   LogAuditRow(p_buy,sent ? "order_sent" : "order_failed");
  }

Every new bar, plus every send/error/zero-lot event, lands one row in the audit CSV via LogAuditRow() (a handful of early returns in OnTick() - an already-open position, an excessive spread, a missing trend-MA value, or simply no signal - fall through without a dedicated row, since the next bar's "observe" row already captures the state), which is deliberately verbose - every row carries the fused probability, all five learned accuracies, the prior, and the outcome, precisely so a column-count mismatch or a silent logic error would show up in the raw CSV rather than staying hidden inside a summary printed only at the end of a run:

LogAuditRow

//+------------------------------------------------------------------+
//| Append one row to the audit CSV                                  |
//+------------------------------------------------------------------+
void LogAuditRow(const double p_buy,const string action)
  {
   if(g_audit_handle == INVALID_HANDLE)
      return;

   FileWrite(g_audit_handle,TimeToString(TimeCurrent(),TIME_DATE|TIME_MINUTES),
             DoubleToString(p_buy,4),
             DoubleToString(g_model.GetAccuracy(0),4),
             DoubleToString(g_model.GetAccuracy(1),4),
             DoubleToString(g_model.GetAccuracy(2),4),
             DoubleToString(g_model.GetAccuracy(3),4),
             DoubleToString(g_model.GetAccuracy(4),4),
             DoubleToString(g_model.GetPrior(),4),
             action);
  }


Wiring it together: OnInit, OnTick, OnTester

OnInit() creates the five LF handles, the trend/ATR handles, initializes the EM model's ring buffer, and sets up the audit log, failing out with INIT_FAILED at the first sign that any indicator handle didn't come back valid rather than limping forward with a partially-initialized EA:

OnInit

//+------------------------------------------------------------------+
//| Expert initialization function                                   |
//+------------------------------------------------------------------+
int OnInit()
  {
   if(!g_lf.Init(_Symbol,_Period,InpRsiPeriod,InpLfMaFastPeriod,InpLfMaSlowPeriod,
                  InpBbPeriod,InpBbDeviation,InpMacdFast,InpMacdSlow,InpMacdSignal))
     {
      Print("OnInit: CLabelingFunctions::Init failed");
      return(INIT_FAILED);
     }

   g_h_trend_ma = iMA(_Symbol,_Period,InpTrendMaPeriod,0,MODE_EMA,PRICE_CLOSE);
   g_h_atr      = iATR(_Symbol,_Period,InpAtrPeriod);
   if(g_h_trend_ma == INVALID_HANDLE || g_h_atr == INVALID_HANDLE)
     {
      Print("OnInit: trend/ATR handle creation failed, GetLastError=",GetLastError());
      return(INIT_FAILED);
     }

   g_model.Init(InpRollingWindowBars);
   g_trade.SetExpertMagicNumber(InpMagic);
   g_trade.SetTypeFillingBySymbol(_Symbol);

   //--- level 2: audit log setup, per the FolderCreate-before-FileOpen fix
   WsEnsureFilesFolder(g_audit_subfolder);
   g_audit_handle = FileOpen(g_audit_filename,FILE_WRITE|FILE_CSV|FILE_ANSI,',');
   if(g_audit_handle == INVALID_HANDLE)
     {
      Print("OnInit: FileOpen(",g_audit_filename,") failed, GetLastError=",
            GetLastError());
     }
   else
     {
      FileWrite(g_audit_handle,"time","p_buy","alpha_rsi","alpha_macross",
                "alpha_bb","alpha_macd","alpha_candle","prior","action");
   }

   g_last_bar_time  = 0;
   g_bars_since_fit = 0;
   return(INIT_SUCCEEDED);
  }

OnDeinit

//+------------------------------------------------------------------+
//| Expert deinitialization function                                 |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
   g_lf.Deinit();
   if(g_h_trend_ma != INVALID_HANDLE) IndicatorRelease(g_h_trend_ma);
   if(g_h_atr      != INVALID_HANDLE) IndicatorRelease(g_h_atr);
   if(g_audit_handle != INVALID_HANDLE) FileClose(g_audit_handle);
  }

IsNewBar() is the usual once-per-bar gate, and OnTick() runs the full loop: collect votes, feed the model, refit on cadence, and compute p_buy (EM or baseline). Then it logs/publishes the value and applies the trend gate and confidence threshold before calling TryOpen(). The refit only runs when both the bar-count cadence has elapsed and the buffer actually holds enough rows to fit - refitting on an under-filled buffer would just re-learn the same seed-dominated estimate over and over for no benefit:

IsNewBar

//+------------------------------------------------------------------+
//| Returns true exactly once per newly closed bar                   |
//+------------------------------------------------------------------+
bool IsNewBar(void)
  {
   datetime t0 = iTime(_Symbol,_Period,0);
   if(t0 != g_last_bar_time)
     {
      g_last_bar_time = t0;
      return(true);
     }
   return(false);
  }

OnTick

//+------------------------------------------------------------------+
//| Expert tick function                                             |
//+------------------------------------------------------------------+
void OnTick()
  {
   if(!IsNewBar())
      return;

   //--- level 2: gather the closed bar's LF votes and feed the model
   int votes[];
   g_lf.GetAllVotes(1,votes);
   g_model.PushVoteRow(votes);
   g_bars_since_fit++;

   if(g_bars_since_fit >= InpRefitEveryBars &&
      g_model.BufferedRows() >= WS_MIN_ROWS_TO_FIT)
     {
      if(g_model.Fit())
         g_bars_since_fit = 0;
     }

   double p_buy = InpUseMajorityVoteBaseline ? WsMajorityVoteProbability(votes)
                                              : g_model.Predict(votes);
   LogAuditRow(p_buy,"observe");
   PublishGlobals(p_buy);

   if(PositionSelect(_Symbol))
      return;                                     // one position at a time

   double spread_pts = (double)SymbolInfoInteger(_Symbol,SYMBOL_SPREAD);
   if(spread_pts > InpMaxSpreadPoints)
      return;

   //--- level 2: base trend gate, a longer EMA distinct from LF2's cross
   double trend_ma[];
   if(CopyBuffer(g_h_trend_ma,0,1,1,trend_ma) != 1)
      return;
   double close_1[];
   if(CopyClose(_Symbol,_Period,1,1,close_1) != 1)
      return;

   bool trend_up   = close_1[0] > trend_ma[0];
   bool trend_down = close_1[0] < trend_ma[0];

   bool want_buy  = g_model.IsFitted() && trend_up   && (p_buy >= InpConfidenceThreshold);
   bool want_sell = g_model.IsFitted() && trend_down && ((1.0 - p_buy) >= InpConfidenceThreshold);

   if(want_buy)
     {
      TryOpen(ORDER_TYPE_BUY,p_buy);
     }
   else if(want_sell)
     {
      TryOpen(ORDER_TYPE_SELL,p_buy);
     }
  }

The trend gate itself is deliberately simple and orthogonal to everything in the fusion layer: a single longer-period EMA, checked against the prior closed bar's close, that has to agree with the fused probability's direction before a trade is even considered. It exists so the fused signal isn't fighting the prevailing direction on every single trade - it is not itself part of the weak-supervision model and contributes no vote to EM.

PublishGlobals() writes p_buy and each learned alpha_j to named GlobalVariables so the indicator in Section 8 can plot them without recomputing anything - this keeps the indicator a pure reader rather than a second, independent EM fit running redundantly alongside the EA's own:

PublishGlobals

//+------------------------------------------------------------------+
//| Publish the fused probability and learned accuracies as global   |
//| variables so a chart-attached WeakSupervision_ProbView indicator |
//| can plot them without recomputing the EM fit itself              |
//+------------------------------------------------------------------+
void PublishGlobals(const double p_buy)
  {
   string tag = _Symbol + "_" + EnumToString(_Period);
   GlobalVariableSet("WS_PBUY_"    + tag,p_buy);
   GlobalVariableSet("WS_ALPHA0_"  + tag,g_model.GetAccuracy(0));
   GlobalVariableSet("WS_ALPHA1_"  + tag,g_model.GetAccuracy(1));
   GlobalVariableSet("WS_ALPHA2_"  + tag,g_model.GetAccuracy(2));
   GlobalVariableSet("WS_ALPHA3_"  + tag,g_model.GetAccuracy(3));
   GlobalVariableSet("WS_ALPHA4_"  + tag,g_model.GetAccuracy(4));
   GlobalVariableSet("WS_FITTED_"  + tag,g_model.IsFitted() ? 1.0 : 0.0);
  }

OnTester() returns the recovery factor as the optimization criterion, and doubles as the EM diagnostics readout - every real run in Section 10 has its learned alpha and coverage numbers pulled straight from this function's Journal output, printed once at the end of a single-pass backtest rather than buried in a per-bar log nobody would scroll through:

OnTester

//+------------------------------------------------------------------+
//| Tester evaluation function                                       |
//| Returns the recovery factor as the optimization criterion and    |
//| prints the learned per-LF accuracies so a single-pass run also   |
//| doubles as the EM-diagnostics readout used in the article.       |
//+------------------------------------------------------------------+
double OnTester()
  {
   double net_profit  = TesterStatistics(STAT_PROFIT);
   double max_dd       = TesterStatistics(STAT_BALANCE_DD);
   double recovery     = (max_dd > 0.0) ? net_profit / max_dd : 0.0;

   PrintFormat("OnTester: net_profit=%.2f max_dd=%.2f recovery=%.4f",
               net_profit,max_dd,recovery);
   PrintFormat("OnTester: learned alpha - rsi=%.4f macross=%.4f bb=%.4f macd=%.4f candle=%.4f",
               g_model.GetAccuracy(0),g_model.GetAccuracy(1),g_model.GetAccuracy(2),
               g_model.GetAccuracy(3),g_model.GetAccuracy(4));
   PrintFormat("OnTester: coverage - rsi=%d macross=%d bb=%d macd=%d candle=%d",
               g_model.GetCoverage(0),g_model.GetCoverage(1),g_model.GetCoverage(2),
               g_model.GetCoverage(3),g_model.GetCoverage(4));

   return(recovery);
  }
//+------------------------------------------------------------------+


The ProbView indicator

A sub-window indicator reads the EA's published GlobalVariables and plots P(Buy) against both threshold lines, so you can watch the fused probability move relative to InpConfidenceThreshold without touching the EA's own logic or running a second copy of the model. It's a pure reader - it holds no state of its own beyond the three plot buffers and a symbol/period tag string:

Indicator properties and input

//+------------------------------------------------------------------+
//|                                WeakSupervision_ProbView.mq5      |
//|                                       https://www.mql5.com       |
//+------------------------------------------------------------------+
#property copyright "Olamide Adebayo"
#property link      "https://www.mql5.com"
#property version   "1.00"
#property indicator_separate_window
#property indicator_buffers 3
#property indicator_plots   3
#property indicator_minimum 0.0
#property indicator_maximum 1.0

#property indicator_label1  "P(Buy)"
#property indicator_type1   DRAW_LINE
#property indicator_color1  clrDodgerBlue
#property indicator_width1  2

#property indicator_label2  "Buy Threshold"
#property indicator_type2   DRAW_LINE
#property indicator_color2  clrLimeGreen
#property indicator_style2  STYLE_DOT

#property indicator_label3  "Sell Threshold"
#property indicator_type3   DRAW_LINE
#property indicator_color3  clrOrangeRed
#property indicator_style3  STYLE_DOT

input double InpConfidenceThreshold = 0.65;   // must match the EA's own input

Indicator buffers

//--- level 1: indicator buffers
double g_buf_pbuy[];   // plotted P(Buy) line
double g_buf_upper[];  // buy-threshold dotted line
double g_buf_lower[];  // sell-threshold dotted line
string g_tag;          // symbol_period key into the EA's globals

OnInit

//+------------------------------------------------------------------+
//| Custom indicator initialization function                         |
//+------------------------------------------------------------------+
int OnInit()
  {
   SetIndexBuffer(0,g_buf_pbuy, INDICATOR_DATA);
   SetIndexBuffer(1,g_buf_upper,INDICATOR_DATA);
   SetIndexBuffer(2,g_buf_lower,INDICATOR_DATA);

   ArraySetAsSeries(g_buf_pbuy, true);
   ArraySetAsSeries(g_buf_upper,true);
   ArraySetAsSeries(g_buf_lower,true);

   g_tag = _Symbol + "_" + EnumToString(_Period);
   IndicatorSetString(INDICATOR_SHORTNAME,"WeakSupervision ProbView");
   IndicatorSetInteger(INDICATOR_DIGITS,4);
   return(INIT_SUCCEEDED);
  }

ReadGlobalOrDefault() is a small guard against the case where the indicator gets attached to a chart before the EA has published anything yet - it falls back to a neutral 0.5 rather than throwing a runtime error or plotting a stale zero:

ReadGlobalOrDefault

//+------------------------------------------------------------------+
//| Read one global variable, defaulting to 0.5 when absent          |
//+------------------------------------------------------------------+
double ReadGlobalOrDefault(const string name,const double fallback)
  {
   if(GlobalVariableCheck(name))
      return(GlobalVariableGet(name));
   return(fallback);
  }

And OnCalculate() mirrors the EA's last published value across whatever new history needs redrawing, gated on the same WS_FITTED flag the EA publishes so the line only appears once the model has actually converged once - before that, the buffer holds EMPTY_VALUE and nothing plots, rather than showing a misleading flat 0.5 line during warm-up:

OnCalculate

//+------------------------------------------------------------------+
//| Custom indicator iteration function                              |
//+------------------------------------------------------------------+
int OnCalculate(const int rates_total,
                 const int prev_calculated,
                 const datetime &time[],
                 const double &open[],
                 const double &high[],
                 const double &low[],
                 const double &close[],
                 const long &tick_volume[],
                 const long &volume[],
                 const int &spread[])
  {
   if(rates_total < 2)
      return(0);

   //--- level 2: EA publishes p_buy once per bar, mirror it across history
   double p_buy = ReadGlobalOrDefault("WS_PBUY_" + g_tag,0.5);
   bool   fitted = ReadGlobalOrDefault("WS_FITTED_" + g_tag,0.0) > 0.5;

   int start = (prev_calculated > 1) ? prev_calculated - 1 : 0;
   for(int i = start; i < rates_total; i++)
     {
      int shift = rates_total - 1 - i;
      g_buf_pbuy[shift]  = fitted ? p_buy : EMPTY_VALUE;
      g_buf_upper[shift] = InpConfidenceThreshold;
      g_buf_lower[shift] = 1.0 - InpConfidenceThreshold;
     }

   return(rates_total);
  }
//+------------------------------------------------------------------+


Testing methodology

This article makes an edge claim - the fused probability gates real entries - so it needed real Strategy Tester runs across multiple symbols and multiple timeframes, not one cherry-picked pair. I ran the full matrix twice: once with InpUseMajorityVoteBaseline = false (EM-weighted) and once with it set to true (plain majority vote), every other input left at its shipped default, same date range, same deposit, on EURUSD, GBPUSD, and XAUUSD at H1 and M15 - twelve runs total, 5,260 combined trades (summing the Trades column of the table below) - enough trades per run to compare behavior, though not enough to establish statistical significance (see the results section). Every run was cross-verified by counting order_sent rows in the audit CSV against the report's own Total Trades figure - a mismatch there would mean the audit log and the actual order flow had drifted apart, which would make every other number in this article suspect. No discrepancies turned up anywhere in the matrix.

For anyone trying to reproduce these numbers exactly, here are the Strategy Tester conditions every one of the twelve runs shared:

Setting Value
Date range 2026.01.01 - 2026.09.14
Server / data source MetaQuotes-Demo (MetaTester build 6290; runs executed 2026-10-28)
Deposit 10,000.00 (account currency)
Leverage 1:100
Tick generation Every tick, 100% real ticks (per-run History Quality)
Spread / commission Broker/demo default for each symbol - not overridden by any custom spread or commission setting

The rolling window (500 bars), refit cadence (every 200 bars), and confidence threshold (0.65) were left at their shipped defaults across all twelve runs deliberately - the point of this article is measuring EM against majority vote under one fixed, reasonable configuration, not hunting for the single input combination that makes either mode look best. A parameter sweep across those three inputs is a natural follow-up but a separate piece of work from what's reported here.

Config Mode Trades Net Profit Profit Factor Max DD
EURUSD H1 EM 192 -1,661.95 0.86 27.34%
EURUSD H1 Baseline 194 -1,635.64 0.87 26.07%
EURUSD M15 EM 680 -3,907.36 0.90 50.37%
EURUSD M15 Baseline 705 -4,582.45 0.88 56.13%
GBPUSD H1 EM 194 -2,078.59 0.81 29.51%
GBPUSD H1 Baseline 199 -2,539.04 0.76 33.45%
GBPUSD M15 EM 660 -6,387.95 0.80 67.68%
GBPUSD M15 Baseline 678 -6,398.03 0.80 68.18%
XAUUSD H1 EM 159 -6,701.32 0.93 84.44%
XAUUSD H1 Baseline 180 -8,741.74 0.87 93.42%
XAUUSD M15 EM 678 -9,569.64 0.98 99.41%
XAUUSD M15 Baseline 741 -9,702.67 0.98 99.55%


Results: does the learned weighting actually help?


Fig. 2. Net profit, EM-weighted versus majority-vote baseline, across all six real Strategy Tester configurations.

Every one of the twelve runs lost money, and the first thing to say about the table above is that it is a table of losses. Within that, EM loses less than the plain majority-vote baseline on five of the six configurations. XAUUSD H1 shows the largest gap: EM loses $2,040.42 less, with a profit factor of 0.93 against 0.87, a Sharpe ratio of -1.07 against -1.80, and a maximum balance drawdown of 84.44% against 93.42%. EURUSD M15 follows at $675.09 less, then GBPUSD H1 at $460.45 less (profit factor 0.81 against 0.76), then XAUUSD M15 at $133.03. GBPUSD M15 differs by only $10.08, which is a tie for any practical purpose.

EURUSD H1 is the exception, and I am reporting it plainly: baseline loses $26.31 less than EM there, with a marginally better profit factor (0.87 against 0.86) and Sharpe ratio (-1.36 against -1.39). One more pattern is worth stating before reading too much into five wins out of six: EM took fewer trades than the baseline on all six configurations (159 against 180 on XAUUSD H1, 678 against 741 on XAUUSD M15, 194 against 199 on GBPUSD H1). In a system that loses money on average, trading less loses less, so part of EM's smaller losses may simply be the confidence gate doing its job of rejecting entries, not better direction calls. The matrix cannot separate those two explanations.

To test whether five out of six could simply be noise, I ran three checks on the same twelve Strategy Tester reports. First, a trade-level bootstrap per configuration: closed-trade P&L was resampled with replacement 10,000 times for each mode independently (the two modes take different trades, so the comparison is unpaired), and the 95% interval of the net-profit difference is shown below and in Fig. 3. Second, a sign test on the six configurations. Third, a paired comparison by configuration and calendar month (54 pairs), using a calendar-month block bootstrap, which keeps the three symbols of the same month together so their shared market conditions are not counted as independent evidence.

Config EM trades Baseline trades Net profit difference (EM - baseline) 95% bootstrap interval
EURUSD H1 192 194 -26.31 -4,683 to +4,691
EURUSD M15 680 705 +675.09 -7,474 to +8,782
GBPUSD H1 194 199 +460.45 -3,450 to +4,391
GBPUSD M15 660 678 +10.08 -6,786 to +6,781
XAUUSD H1 159 180 +2,040.42 -36,379 to +40,229
XAUUSD M15 678 741 +133.03 -134,219 to +134,942


Fig. 3. Net profit difference between EM and majority vote per configuration, with 95% trade-level bootstrap intervals. The two gold intervals extend beyond the plotted range, as marked by the arrows and the printed values.

Every interval contains zero, and the intervals are between roughly 17 and well over a thousand times wider than the differences they surround. The sign test on five wins out of six gives p = 0.109 one-sided (0.219 two-sided). In the 54 paired config-months, EM was better in 29 and worse in 25; the mean difference is +0.61% of the deposit per config-month, with a calendar-month block bootstrap interval of roughly -9.7% to +11.8% and a sign-flip permutation p-value of about 0.47. Dropping the gold runs, whose near-total drawdowns dominate the scale, leaves EM better in 19 config-months and worse in 17, with a mean difference of +0.31%. The honest reading: this matrix is consistent with EM and majority vote performing the same, and it cannot show that the learned weighting adds value. What it does show is the mechanism working as designed: each labeling function gets an inspectable weight. Establishing an actual P&L benefit would need many more non-overlapping periods, a walk-forward design, and a labeling-function set that is profitable on its own.


Fig. 4. Learned per-labeling-function accuracy (alpha_j) from the final EM fit of each configuration. The ceiling value 0.999 is the WS_ALPHA_CEIL clamp, not a measured perfect score.

The heatmap is the real payoff of running EM instead of majority vote: no labeling function is equally trustworthy everywhere. Read the top row with care, though. RSI shows 0.999 on all six configurations, but that is the clamp value, and the coverage lines in the Journal show why: in the final fit window RSI cast only 9 to 18 non-abstain votes out of 500 bars, so an accuracy estimate pinned at the ceiling rests on very few votes and should not be read as RSI being almost always right. The same caution applies, less strongly, to Bollinger (27 to 34 non-abstain votes) and to the EMA cross (10 to 33). The candle proxy votes most often (201 to 272 non-abstain votes) and is the best-estimated of the five.

The EMA cross is the volatile one: it collapses to a literal coin flip on GBPUSD H1 (0.50) and XAUUSD H1 (0.50), drops to 0.58 on XAUUSD M15, and sits at 0.78-0.86 on the other three. That is EM estimating, purely from vote-agreement patterns and with zero access to outcomes, that the cross agrees with the other labeling functions no more often than a coin flip on those pair/timeframe combinations, so it barely moves the fused probability there. Whether that vote is also uninformative about price direction is something the vote matrix alone cannot confirm. A fixed-weight or majority-vote scheme has no mechanism to notice this and would keep giving that vote full say on every bar. MACD shows a similar spread, from 0.69 on XAUUSD M15 to 0.999 on EURUSD H1 and M15, and the candle proxy ranges from 0.70 on XAUUSD H1 to 0.92 on GBPUSD M15. Engulfing and pin-bar patterns are regime-dependent in how informative they are, and the estimate shifts with the vote pattern without anyone telling the model that the regime changed. The alphas are identical in the EM and baseline runs of each configuration, because the label model is fitted in both modes; only the way the probability is turned into an entry differs.


Fig. 5. Maximum balance drawdown, EM-weighted versus majority-vote baseline, across all six configurations.

Drawdown tells the same cross-run story: M15 is worse than H1 on every pair, in both modes, and XAUUSD is categorically more dangerous than either FX pair under identical risk inputs. Gold reaches 84% to 93% maximum balance drawdown on H1 and about 99% on M15, against 26% to 33% (H1) and 50% to 68% (M15) on EURUSD and GBPUSD. EM's drawdown is lower than the baseline's on five of six configurations, with the largest gap again on XAUUSD H1 (84.44% against 93.42%); EURUSD H1 is the exception, 27.34% against 26.07%. The ATR-multiplier position sizing here is not instrument-aware, and gold's price scale breaks it; that is a limitation of the risk layer, not of the weak-supervision idea, and it would need a per-symbol ATR-to-lot calibration - scaling the risk percentage down specifically for high-point-value instruments - to fix properly rather than trusting one fixed InpRiskPercent to behave sanely everywhere.


Limitations and what's next

Three honest gaps worth naming before anyone tries to trade this as-is. First, the position-sizing layer needs per-instrument calibration - the same InpAtrSlMultiplier/InpAtrTpMultiplier/InpRiskPercent triple that behaves reasonably on FX is what drove gold's maximum drawdown to 84-93% on H1 and about 99% on M15; a follow-up would scale the ATR multiplier or the risk percentage by the instrument's point value rather than trusting one set of defaults everywhere. Second, the five labeling functions here are illustrative, not exhaustive or individually optimized - a real deployment would want more of them, deliberately chosen to be as uncorrelated as possible, since EM's whole mechanism depends on the LFs not all failing in the same correlated way at once; five highly-correlated LFs would give EM very little real disagreement to learn from. Third, this article measured EM against majority vote under one fixed set of rolling-window, refit-cadence, and confidence-threshold inputs; a full sensitivity sweep across those three would show whether the five-of-six result is robust across the input space or specific to these particular defaults - and as the results section shows, even at these defaults it is not statistically distinguishable from a tie.


Conclusion

Every one of the twelve runs in this matrix lost money - there is no cherry-picked winner here, and I'm not going to pretend otherwise. What the matrix does show is that the EM-weighted fusion lost less than plain majority vote on five of six real configurations, while also taking fewer trades on all six, by margins that the bootstrap and sign tests cannot separate from noise (sign test p = 0.109 one-sided), and that the mechanism doing the weighting is visible and inspectable: GetAccuracy() tells you exactly which labeling function EM trusted and by how much, on every single run, which a black-box ensemble never would.

The honest limitations: this system has no statistical edge as shipped - the LFs and risk parameters here are illustrative, not tuned for profitability, and the position-sizing layer needs per-instrument calibration before gold and FX can share one set of ATR multipliers. What EM does prove, on real data with no synthetic numbers anywhere in this article, is that learning per-labeling-function trust from agreement patterns alone - with no ground truth, no manual weights - changes trading behavior in an inspectable way; whether it changes it for the better remains an open question on this evidence. If you're combining more than two or three noisy heuristics into one signal, that's a genuinely different starting point than either picking a favorite or voting them all equally.


Programs used in the article

# Name Type Description
1 WeakSupervisionLib.mqh Include File Complete MQL5 library implementing the Weak Supervision framework. Contains five labeling functions based on RSI, EMA crossover, Bollinger Bands, MACD, and candlestick patterns, along with an Expectation-Maximization (EM) model for combining noisy trading signals. Supports probabilistic label generation, estimation of labeling-function accuracy, majority-vote comparison, and risk-management utilities.
2 WeakSupervision_EA.mq5 Expert Advisor Demonstration Expert Advisor integrating weak supervision into an automated trading strategy. Collects signals from five technical labeling functions, trains an EM-based probabilistic model using a rolling window, and executes trades when the estimated probability exceeds a configurable confidence threshold. Includes an EMA trend filter, ATR-based stop-loss and take-profit levels, risk-based position sizing, periodic model retraining, and CSV audit logging. Supports comparison with a simple majority-voting baseline.
3 WeakSupervision_ProbView.mq5 Custom Indicator Visualization indicator displaying the probability of a buy signal estimated by the Weak Supervision Expert Advisor. Shows the estimated P(Buy) value alongside configurable buy and sell confidence thresholds in a separate indicator window. Reads the current model output from MetaTrader 5 terminal global variables. Attach it to the same symbol and timeframe as the running Expert Advisor.
4 MQL5.zip Archive The archive contains all three source files. The Expert Advisor uses WeakSupervisionLib.mqh for signal generation and probabilistic modeling, while the indicator visualizes the model's current output.

Note: The indicator displays the current published probability; it does not reconstruct historical probabilities from the CSV audit log.

Attached files |
MQL5.zip (12.54 KB)
Local Outlier Factor: Density-Based Anomaly Detection in Native MQL5 Local Outlier Factor: Density-Based Anomaly Detection in Native MQL5
This article builds a native MQL5 LOF engine without DLLs, ALGLIB or ONNX. Bars are mapped to five standardized features; k‑NN reachability and density produce a scale‑free LOF score, and the alarm level is a moving quantile of the reference cloud. The score gates only new entries in an EMA‑cross EA, with an indicator for charting and a script for independent checks on XAUUSD, EURUSD and GBPUSD.
Building a High-Volume Pivot Indicator in MQL5 Building a High-Volume Pivot Indicator in MQL5
This article presents a high-volume pivot indicator in MQL5 that identifies confirmed pivot highs and lows using surrounding price bars and normalized volume strength. It calculates rolling volume sums, determines a 95th-percentile volume reference, and filters pivot points based on their relative volume. The indicator uses ATR(500) to scale concentric rings around qualifying pivots and draws reaction levels, pivot markers, and formatted volume labels on the chart, helping you study high-volume pivot areas and their interaction with price structure and market volatility.
Building AI-Powered Trading Systems in MQL5 (Part 13): Letting the Assistant Execute Trades Behind an Approval Card Building AI-Powered Trading Systems in MQL5 (Part 13): Letting the Assistant Execute Trades Behind an Approval Card
We extend an MQL5 AI assistant from read-only tools to safe trade execution with a human-in-the-loop gate. The article defines action tools for placing, closing, modifying, and deleting trades and for drawing on the chart, validates each request, sizes volume to broker limits, and scopes ownership. We pause the tool loop behind an approval card, queue actions, and resume execution only after approval, which holds while auto-approval stays off, as it is by default.
Neural Networks in Practice: The Birth of C_Neuron Neural Networks in Practice: The Birth of C_Neuron
This article demonstrates how to encapsulate a neuron in MQL5 using the C_Neuron class, with weights, a bias, and a parameter specifying the number of inputs. We will take a detailed look at how to compute the loss function based on the least squares method and how to organize training data in arrays. This makes it easy to change the inputs and repeat experiments without modifying the implementation.