preview
Machine Learning Without the Black Box: The Tsetlin Machine for Trading

Machine Learning Without the Black Box: The Tsetlin Machine for Trading

MetaTrader 5Indicators |
228 1
Hammad Dilber
Hammad Dilber

Contents

  1. Introduction
  2. What the Machine Delivers
  3. The Idea: Automata That Vote on Rules
  4. The Tsetlin Automaton
  5. The Clause: One AND-Rule
  6. The Machine: Voting Across Classes
  7. Training: Two Kinds of Feedback
  8. Proving It Learns: Ground Truth
  9. The Booleanizer: Turning Indicators into Bits
  10. Building the Dataset and Labeling by Forward Return
  11. Training on Market Data and Reading the Rules
  12. Saving the Model and a White-Box Panel
  13. Conclusion


Introduction

Suppose you train a neural network on a handful of indicators and it starts calling the next bar's direction. It works often enough to be interesting, and then one day it takes a position that makes no sense to you. You want to ask it a simple question: what did you see that made you buy? A neural network cannot answer. Its knowledge is spread across hundreds of floating-point weights that do not translate back into anything a trader can read. You are left trusting a number you cannot interrogate.

The Tsetlin Machine, introduced by Ole-Christoffer Granmo in 2018, is a different kind of classifier that was built to answer exactly that question. It does not learn weights. It learns logical rules of the form "IF condition A AND condition B AND NOT condition C THEN class", and you can print those rules and read them. It uses an almost startlingly simple mechanism: a bank of tiny state counters. Each counter decides whether a condition belongs in a rule and is nudged up or down by reward or penalty. There is no gradient, no matrix inversion, no activation function. The entire model is integers and comparisons.

That property makes it a natural fit for MQL5. There is no dependency on ALGLIB or ONNX, nothing to link, nothing to convert. The trained model is small enough to fit in a plain text file. Inference is a handful of integer comparisons per bar, so it runs comfortably inside the Strategy Tester and on a live chart. And because the model is a list of readable rules, it gives you something a black box never can: a live, plain-language account of what it thinks the market looks like at this moment.

This article implements the method as a small MQL5 library and explains it layer by layer: the automaton, the clause, the multi-class machine, and the training procedure. Every stage is verified against an independent Python reference, and the classifier is proven on boolean tasks whose answers are known by hand, including XOR, before it is ever pointed at a price chart. We then booleanize a set of indicators, build a labeled dataset from history, train on it, and print the rules the machine learned. The article keeps one distinction honest throughout: the Tsetlin Machine is an interpretable classifier, not a profit oracle, and the market results are reported exactly as they came out.

Who is this for? A developer who is comfortable in MQL5, has met the usual black-box classifiers, and wants a model whose decisions can be read, argued with, and edited. You do not need a background in the Tsetlin Machine literature. Every layer is built from the bottom up, and the only mathematics involved is adding or subtracting one from an integer.


What the Machine Delivers

It helps to see the output before taking the method apart. After training on historical bars, the machine holds a set of rules per class. You can print them, and they read as plain logic. Here is the kind of thing that comes out for the BUY class on EURUSD H1:

---- class BUY : learned rules ----
   clause  6 (+) : bigBody AND higherHigh
   clause  8 (+) : NOT RSI>70 AND NOT RSI>50 AND NOT close>MAfast AND NOT close>MAslow

Each line is a rule the machine assembled on its own out of the boolean features we gave it. The first says a strong-bodied bar that also makes a higher high supports the BUY class. Nothing about that rule is hidden. You can agree with it, argue with it, or throw the feature away, precisely because you can read it. That is the property this whole article is built to demonstrate.

The second artifact is a chart panel. It loads a trained model and, on every closed bar, decodes the current market into the same readable rules, showing which ones are firing right now and how cleanly the active logic points one way versus being scattered. It does not draw an arrow and it does not predict price. It shows you the machine's reasoning in real time.

Chart panel listing the boolean features that are ON, the count of firing rules per class, a conviction meter, and the readable AND-rules currently active

Fig. 1. The active-logic panel. To reproduce, attach TM_ActiveRules to a chart after training a model.

That is what the article delivers: a classifier you can read, and a way to watch it think. The rest explains how it works, starting from the smallest piece.


The Idea: Automata That Vote on Rules

The Tsetlin Machine is built from four ideas stacked on top of each other. Understanding them in order makes the code obvious, so we introduce them from the bottom up before writing any of it.

At the bottom is the Tsetlin automaton, a single decision that answers one yes-or-no question: should this one condition be part of this one rule? It holds that decision as an integer state and changes its mind only when rewarded or penalized. It is the atom of learning here, and it replaces the floating-point weight of a neural network.

A group of these automata forms a clause, which is a single AND-rule. Each automaton in the clause governs one condition, deciding whether that condition is included. The clause outputs 1 when every included condition holds and 0 otherwise, exactly like an AND gate over the conditions it chose to keep.

A collection of clauses forms the machine for one class. Half the clauses vote in favor of the class and half vote against it, and the class score is simply the votes for minus the votes against. With one such machine per class, prediction is the class with the highest score. This is majority voting over learned rules.

Training is the fourth idea, and it is where the automata get their rewards and penalties. Rather than a single loss and a gradient, the Tsetlin Machine uses two targeted feedback rules, one that reinforces correct patterns and one that suppresses false alarms. We build the first three ideas as code, then spend a full section on the fourth.

One vocabulary point that pays off immediately. Each raw feature is given to the machine together with its negation. If a feature is "RSI is above 50", the machine also receives "RSI is not above 50" as a separate condition. Feature and negation together are called literals. For N features there are 2N literals, and a clause can include any subset of them. This is what lets a rule contain a "NOT" term, which the BUY rule above used twice.

Four stacked boxes: a single automaton at the bottom, a clause built from a bank of automata, a per-class bank of clauses, and multi-class argmax voting at the top

Fig. 2. The four layers, bottom to top: automaton, clause, per-class clause bank, and multi-class argmax voting.


The Tsetlin Automaton

The automaton is a counter with a memory of its own decision. Give it a number of states, say 2N of them. The lower half, states 1 to N, mean the action EXCLUDE: keep this condition out of the rule. The upper half, states N+1 to 2N, mean the action INCLUDE: put this condition into the rule. Which action the automaton takes is decided purely by which half its state sits in.

Reward and penalty move the state. A reward pushes the state deeper into the half it is already in, making the current decision more confident and harder to flip. A penalty pushes it toward the boundary and, if it is already at the boundary, across it into the opposite action. There is no arithmetic beyond adding or subtracting one, and the state never leaves the range 1 to 2N.

A horizontal line of states 1 to 2N; states 1 to N labeled EXCLUDE, states N+1 to 2N labeled INCLUDE, with reward arrows pointing away from the center boundary and penalty arrows pointing toward and across it

Fig. 3. The automaton as a line of 2N states. Reward moves away from the center boundary; penalty moves toward it and flips the action when it crosses.

//+------------------------------------------------------------------+
//| CTsetlinAutomaton - a single 2N-state include/exclude counter    |
//|                                                                  |
//|  States 1..N          -> action EXCLUDE (literal not in clause)  |
//|  States N+1..2N       -> action INCLUDE (literal is in clause)   |
//|                                                                  |
//|  Reward pushes the state deeper into its current action's half;  |
//|  penalty pushes it toward (and eventually across) the boundary.  |
//|  The whole "learning" of a Tsetlin Machine is millions of these  |
//|  +1 / -1 moves - there is no floating-point anywhere.            |
//|                                                                  |
//|  We do not instantiate this as an object per literal (that would |
//|  be far too heavy); instead CClause stores a flat int[] of       |
//|  states and applies these transition rules inline. This class is |
//|  kept as the conceptual/documentation unit and for unit tests.   |
//+------------------------------------------------------------------+
class CTsetlinAutomaton
  {
private:
   int               m_state;   // current state in 1..2N
   int               m_n;       // half the number of states (N)

public:
                     CTsetlinAutomaton() : m_state(0), m_n(0) {}

   void              Init(int n, int state)
     {
      m_n     = n;
      m_state = state;
     }

//--- action decided purely by which half we are in
   bool              IsInclude() const { return (m_state > m_n); }
   int               State()     const { return m_state; }

//--- reward: strengthen the current action (move away from boundary)
   void              Reward()
     {
      if(m_state > m_n)
        {
         if(m_state < 2 * m_n)
            m_state++;   // deeper into INCLUDE
        }
      else
        {
         if(m_state > 1)
            m_state--;   // deeper into EXCLUDE
        }
     }

//--- penalty: weaken the current action (move toward boundary)
   void              Penalty()
     {
      if(m_state > m_n)
        {
         m_state--;      // toward EXCLUDE
        }
      else
        {
         m_state++;      // toward INCLUDE
        }
     }
  };

This class exists mostly to make the concept concrete. Notice the asymmetry between reward and penalty. Reward is guarded so the state cannot run past the ends of its range, which keeps a confident decision from overflowing. Penalty is unguarded at the boundary on purpose: that is the single step where an automaton actually flips its action, from EXCLUDE to INCLUDE or back. Learning happens at that boundary.

In the working machine we do not keep one object per literal, because a machine with twenty clauses over ten features already has hundreds of automata and thousands more once you count classes. Instead the clause stores a flat array of states and applies these same two transitions inline. The class above is the readable statement of the rule; the array is the efficient form of it.


The Clause: One AND-Rule

A clause is where the automata become a rule. It owns 2N literal states, one per literal, initialized to the borderline EXCLUDE state so that a fresh clause starts empty and includes nothing. A literal is included in the rule when its state has crossed into the INCLUDE half. The clause's output on an input is the AND over its included literals: if any included literal is false, the output is 0; otherwise it is 1.

The allocation happens in Init. It computes the literal count as twice the feature count, sizes the state array once to that count, and sets every automaton to m_half, the last state on the EXCLUDE side of the boundary. Starting on the boundary rather than deep in one half is deliberate: it means a single reward can flip any literal into the rule, so a fresh clause is maximally free to learn.

//--- allocate literal bank; every automaton starts just on the
//--- EXCLUDE side of the boundary (state N) so clauses begin empty
   void              Init(int nFeatures, int nStates)
     {
      m_nFeatures = nFeatures;
      m_nLiterals = 2 * nFeatures;
      m_nStates   = nStates;
      m_half      = nStates / 2;
      ArrayResize(m_ta, m_nLiterals);
      for(int i = 0; i < m_nLiterals; i++)
         m_ta[i] = m_half;          // borderline EXCLUDE
     }

Two details in Evaluate matter later. The first is the mapping in LiteralValue: literal indices 0 to N-1 read the feature directly, and indices N to 2N-1 read its negation. That single line is what gives clauses the ability to require "NOT" conditions. The second is the empty-clause rule. A clause with no included literals returns 1 during training and 0 during inference. The training value of 1 lets a fresh clause take part in feedback so it can start learning; the inference value of 0 stops empty clauses from flooding the vote with meaningless support. The predict flag selects between the two.

A boolean input vector on the left feeding into an AND gate that only takes the literals marked INCLUDE, producing a single 0 or 1 clause output on the right

Fig. 4. A clause is an AND over just its included literals. It outputs 1 only when every included literal is true.

The feedback rules move literals one step at a time, so the clause exposes two tiny helpers for exactly that. StepToInclude nudges a literal one state toward INCLUDE without running past the top, and StepToExclude nudges it one step toward EXCLUDE without dropping below one. They are the only writes the learner performs on a clause.

//--- push a literal one step toward INCLUDE (Type I on true literals)
   void              StepToInclude(int lit)
     {
      if(m_ta[lit] < m_nStates)
         m_ta[lit]++;
     }

//--- push a literal one step toward EXCLUDE (Type I "forget" on false)
   void              StepToExclude(int lit)
     {
      if(m_ta[lit] > 1)
         m_ta[lit]--;
     }

The clause also renders itself as text, which is the whole reason the model is readable. ToString walks the literals, skips the excluded ones, and prints each included literal as its feature name, or "NOT" plus the name for a negated literal, joining them with "AND". An empty clause prints "(empty)". Interpretability is not bolted on afterward; it is a direct read of the clause's own state.

//--- render the learned rule as text, using caller-supplied names
   string            ToString(const string &names[]) const
     {
      string parts = "";
      for(int lit = 0; lit < m_nLiterals; lit++)
        {
         if(m_ta[lit] <= m_half)
            continue;
         string term;
         if(lit < m_nFeatures)
            term = names[lit];
         else
            term = "NOT " + names[lit - m_nFeatures];
         if(parts != "")
            parts += " AND ";
         parts += term;
        }
      if(parts == "")
         return "(empty)";
      return parts;
     }

The names array comes from the booleanizer, built later, and the same array is what the training script and the chart panel both use to print rules. Because the clause stores only integer states, ToString renders rules identically in training, in dumps, and on live charts.


The Machine: Voting Across Classes

The machine holds one bank of clauses per class. Within a class, clauses alternate polarity by index: even-indexed clauses are positive and vote for the class, odd-indexed clauses are negative and vote against it. The class score is the number of positive clauses that fire minus the number of negative clauses that fire. Prediction is the class with the highest score.

//+------------------------------------------------------------------+
//| Class score: positive clauses add, negative clauses subtract     |
//+------------------------------------------------------------------+
int CTsetlinMachine::ClassScore(int k, const int &x[], bool predict) const
  {
   int score = 0;
   for(int c = 0; c < m_clausesPerClass; c++)
     {
      int out = m_clauses[Idx(k, c)].Evaluate(x, predict);
      if(out == 0)
         continue;
      if(c % 2 == 0)
         score += 1;   // positive clause
      else
         score -= 1;   // negative clause
     }
   return score;
  }

//+------------------------------------------------------------------+
//| Predict class = argmax score (ties -> lowest class index)        |
//+------------------------------------------------------------------+
int CTsetlinMachine::Predict(const int &x[]) const
  {
   int best     = 0;
   int bestScore = ClassScore(0, x, true);
   for(int k = 1; k < m_nClasses; k++)
     {
      int sc = ClassScore(k, x, true);
      if(sc > bestScore)
        {
         bestScore = sc;
         best      = k;
        }
     }
   return best;
  }

The reason for negative clauses is worth stating plainly, because a first implementation is tempting to write without them. Positive clauses learn what a class looks like. Negative clauses learn what it does not look like, and they subtract support when a rival pattern appears. Without them a class can only ever accumulate evidence, never veto it, and classes that overlap in feature space become impossible to separate cleanly. Later, when we train on noisy data, the negative clauses of the BUY machine end up learning the SELL pattern and voting it down. That is the machine discovering, on its own, that a sell-like bar is evidence against buying.

Three class banks, each with positive clauses adding and negative clauses subtracting to a class score, and an argmax picking the highest-scoring class as the prediction

Fig. 5. Voting across three classes. Positive clauses add, negative clauses subtract, and the argmax over the scores is the prediction.

The clauses are stored in a single flat array indexed by Idx(k, c), which maps class k and clause c to k * clausesPerClass + c. Allocation happens once in Init, which sizes the whole clause array to the exact total and then initializes each clause in place:

//+------------------------------------------------------------------+
//| Allocate all clauses and initialize hyperparameters             |
//+------------------------------------------------------------------+
void CTsetlinMachine::Init(int nClasses, int nFeatures, int clausesPerClass,
                           int nStates, int T, double s, int seed)
  {
   m_nClasses        = nClasses;
   m_nFeatures       = nFeatures;
   m_clausesPerClass = clausesPerClass;
   m_nStates         = nStates;
   m_T               = T;
   m_s               = s;
   m_seed            = seed;

   ArrayResize(m_clauses, nClasses * clausesPerClass);
   for(int i = 0; i < nClasses * clausesPerClass; i++)
      m_clauses[i].Init(nFeatures, nStates);
  }

The key point is resizing once to the exact total. A modest machine with three classes, twenty clauses each, and ten features already holds three times twenty times twenty automaton states, and growing the array one clause at a time would make setup an O(N^2) cost for no reason. Sizing it once keeps allocation linear. The random number generator is a small linear congruential generator seeded here, so training is reproducible and does not depend on the global MathRand state.


Training: Two Kinds of Feedback

Training replaces the gradient of a neural network with two feedback rules, applied to individual clauses. There is no loss function to differentiate. Instead, on each labeled example, the machine reinforces the clauses that should recognize the example and corrects the clauses that raised a false alarm.

The scheme is one-vs-rest. For each training example, the machine treats the true class as the one that should score high and one randomly chosen rival class as one that should score low. Sampling a single rival per update, rather than all of them, keeps each update cheap on long histories while still, over many bars, pushing every rival down. The direction, high or low, decides which clauses in that class should fire and therefore which feedback rule each clause receives.

//+------------------------------------------------------------------+
//| One-vs-rest update over a single labelled example                |
//|                                                                  |
//|  For the target class we apply Type I feedback (reinforce true   |
//|  patterns). For every other class we apply Type II feedback      |
//|  (suppress false positives). Standard multi-class TM scheme.     |
//+------------------------------------------------------------------+
void CTsetlinMachine::Update(const int &x[], int targetClass)
  {
//--- the target class is trained to output +1 (score should rise);
//--- one randomly sampled rival class is trained to output -1
//--- (score should fall). This is the standard one-vs-rest scheme.
   FeedbackClass(targetClass, x, +1);

   if(m_nClasses > 1)
     {
      int neg = (int)(Rand() * m_nClasses);
      if(neg == targetClass)
         neg = (neg + 1) % m_nClasses;
      FeedbackClass(neg, x, -1);
     }
  }

Inside a class, feedback is routed per clause. Whether a clause should fire depends on both its polarity and the class direction. When the class should score high, its positive clauses should fire and its negative clauses should not. When the class should score low, it is the reverse. A clause that should fire receives Type I feedback. A clause that should not fire receives Type II feedback. The routing is one boolean:

//+------------------------------------------------------------------+
//| Feedback for one sub-machine k toward a desired sign             |
//|                                                                  |
//|  desired = +1 : we want a HIGH class score (this is the true     |
//|                 class of x). Positive clauses should fire on x,  |
//|                 negative clauses should NOT.                     |
//|  desired = -1 : we want a LOW class score (x belongs elsewhere). |
//|                 Positive clauses should NOT fire, negative       |
//|                 clauses SHOULD.                                  |
//|                                                                  |
//|  A clause whose "should fire" target is 1 receives Type I        |
//|  feedback (recognise / erase); one whose target is 0 receives    |
//|  Type II feedback (reject false positives). The score is clamped |
//|  to [-T, T] and turned into a probability so feedback tapers off |
//|  once the margin is reached - this is what keeps the clause bank |
//|  diverse instead of collapsing onto one rule.                    |
//+------------------------------------------------------------------+
void CTsetlinMachine::FeedbackClass(int k, const int &x[], int desired)
  {
   int score = ClassScore(k, x, false);
   if(score >  m_T)
      score =  m_T;
   if(score < -m_T)
      score = -m_T;

//--- probability that a given clause is selected for update
   double pFeed = (desired > 0)
                  ? (double)(m_T - score) / (double)(2 * m_T)
                  : (double)(m_T + score) / (double)(2 * m_T);

   for(int c = 0; c < m_clausesPerClass; c++)
     {
      if(Rand() > pFeed)
         continue;                     // stochastic clause selection

      bool positive = (c % 2 == 0);
      CClause *cl   = GetPointer(m_clauses[Idx(k, c)]);

      //--- should THIS clause fire on x, given the class target?
      //---   desired +1: positive->1, negative->0
      //---   desired -1: positive->0, negative->1
      bool wantFire = (desired > 0) ? positive : !positive;

      if(wantFire)
         ClauseTypeI(cl, x);
      else
         ClauseTypeII(cl, x);
     }
  }

The probability pFeed is the machine's self-regulation. Two parameters govern it: the target margin T and the specificity s. The class score is first clamped to the range from minus T to T. When the score is already near the correct side of the margin, pFeed is small, so few clauses are selected and the machine stops piling more weight onto a decision it has already made. When the score is on the wrong side, pFeed is large and many clauses are corrected. This is what keeps the clause bank diverse: clauses are only pushed as hard as the current error demands, so they do not all collapse onto the same rule.

A decision tree: the desired class direction and each clause's polarity combine into a should-fire boolean, which routes the clause to Type I feedback if it should fire or Type II feedback if it should not

Fig. 6. Feedback routing. Class direction and clause polarity decide whether a clause should fire: if it should, Type I feedback, otherwise Type II.

Type I feedback reinforces a clause that should fire. If the clause already fires, its true literals are strongly pushed toward INCLUDE and its false literals are weakly pushed toward EXCLUDE, so the rule tightens around the pattern it just recognized. If the clause fails to fire, its included literals are eroded so it can activate again. The specificity s sets the strength: true literals are included with probability 1 minus 1/s, and forgetting happens with probability 1/s.

//+------------------------------------------------------------------+
//| Type I feedback on a single clause ("recognise" / "erase")       |
//|                                                                  |
//|  Applied when the clause is SUPPOSED to fire on x. If it already |
//|  fires, reinforce the literals that agree with x (true literals  |
//|  strongly included, false literals weakly excluded). If it does  |
//|  not fire, erode included literals so it can activate again.     |
//|  The 1/s probabilities give Type I its characteristic "boost     |
//|  frequent patterns, forget rare noise" behaviour.                |
//+------------------------------------------------------------------+
void CTsetlinMachine::ClauseTypeI(CClause *cl, const int &x[])
  {
   int clauseOut = cl.Evaluate(x, false);

   if(clauseOut == 1)
     {
      for(int lit = 0; lit < cl.Literals(); lit++)
        {
         int lv = cl.LiteralValue(x, lit);
         if(lv == 1)
           {
            if(Rand() > 1.0 / m_s)
               cl.StepToInclude(lit);   // true literal -> include
           }
         else
           {
            if(Rand() <= 1.0 / m_s)
               cl.StepToExclude(lit);   // false literal -> exclude
           }
        }
     }
   else
     {
      for(int lit = 0; lit < cl.Literals(); lit++)
         if(Rand() <= 1.0 / m_s)
            cl.StepToExclude(lit);      // erase to re-activate
     }
  }

Type II feedback corrects a clause that should not fire but currently does. It makes the clause more specific by including one of its excluded literals that is false in the current input. Adding a false literal to the AND forces the clause to fail on this pattern next time, removing the false positive. This step is deterministic in the standard formulation, and it is the only place a false alarm is actively suppressed.

//+------------------------------------------------------------------+
//| Type II feedback on a single clause ("reject false positive")    |
//|                                                                  |
//|  Applied when the clause should NOT fire on x but does.          |
//|  We make it more specific by including an excluded literal that  |
//|  is FALSE in x - that forces the AND to fail on this pattern in  |
//|  future. Deterministic in the standard formulation.              |
//+------------------------------------------------------------------+
void CTsetlinMachine::ClauseTypeII(CClause *cl, const int &x[])
  {
   if(cl.Evaluate(x, false) == 0)
      return;               // only firing clauses need correction

   for(int lit = 0; lit < cl.Literals(); lit++)
     {
      if(cl.IsInclude(lit))
         continue;
      if(cl.LiteralValue(x, lit) == 0)
         cl.StepToInclude(lit);
     }
  }

That is the whole learner. Type I builds and tightens rules that recognize a class, Type II carves away the patterns that trigger a rule wrongly, and pFeed decides how much of either to apply based on how far the current score is from the margin. Every operation is an integer step or a comparison. There is nothing else.

The convenience wrapper Fit runs this update over a whole dataset for a number of epochs. It copies each row into a small feature buffer and calls Update. The dataset is stored row-major, so row i starts at offset i times the feature count, and the same layout is reused for evaluation.

//+------------------------------------------------------------------+
//| Fit E epochs over a row-major dataset                            |
//+------------------------------------------------------------------+
void CTsetlinMachine::Fit(const int &X[], const int &y[], int nSamples, int epochs)
  {
   int xi[];
   ArrayResize(xi, m_nFeatures);
   for(int e = 0; e < epochs; e++)
     {
      for(int i = 0; i < nSamples; i++)
        {
         int base = i * m_nFeatures;
         for(int f = 0; f < m_nFeatures; f++)
            xi[f] = X[base + f];
         Update(xi, y[i]);
        }
     }
  }


Proving It Learns: Ground Truth

Before trusting the machine on a price chart, it has to be proven on tasks whose correct answers are known by hand. The script TM_Test_GroundTruth.mq5 trains the machine on four boolean truth tables and verifies 100% accuracy on each. The important one is XOR, the exclusive-or function, because XOR is not linearly separable and famously cannot be solved by a single-layer perceptron. If the machine solves XOR, its rule-based capacity is real and not an illusion of easy data.

//--- 1) XOR : y = x0 ^ x1   (linearly NON-separable - the key test)
     {
      int X[] = { 0,0,  0,1,  1,0,  1,1 };
      int y[] = { 0,    1,    1,    0    };
      double acc = RunTask("XOR", X, y, 4, 2, 2, names2);
      Check("XOR accuracy == 100%", acc >= 0.999,
            StringFormat("acc=%.3f", acc));
     }

The machine passes all four tasks, but the accuracy number is only half the point. Because the model is readable, we can also dump what it learned, and the XOR rules are exactly the definition of XOR written back out:

== Task: XOR  (4 samples, 2 features, 2 classes) ==
    class 1 clause 0(+): x1 AND NOT x0
    class 1 clause 4(+): x0 AND NOT x1
  [PASS] XOR accuracy == 100%                acc=1.000

Class 1 of XOR is true when exactly one input is on. The machine learned two positive clauses for it: "x1 AND NOT x0" and "x0 AND NOT x1". Their union is precisely the set of inputs where XOR is 1. No one told the machine the shape of XOR; it assembled that logic from rewards and penalties alone, and it wrote it in a form you can read. The AND and OR tasks pass the same way, and a three-class task modeled on BUY, SELL, and flat confirms the multi-class voting works end to end.

Every result in this article was cross-checked against an independent Python re-implementation of the same feedback rules, so the behavior is not an artifact of one language. On the XOR, AND, OR, and three-class tasks the Python reference reaches the same 100% accuracy and the same style of rules. It also exposed a real bug during development, described next, which is the kind of thing a verification pass is for.

Important: a first version of the feedback left the negative clauses inert, because they were never routed to receive learning. On clean tasks such as XOR the machine still scored 100%, so the bug was invisible there. It only showed up on noisy, overlapping data, where half the model's capacity sat unused. The fix was to make feedback symmetric across polarity, which is the FeedbackClass shown above. The lesson generalizes: on separable data a partly broken classifier can still look perfect, so verify on data that forces every part of the model to work.


The Booleanizer: Turning Indicators into Bits

The machine only understands bits, so the bridge from a price chart to the machine is a component that turns indicators into a fixed-length vector of 0s and 1s. This is CBooleanizer, and it is where trading domain knowledge enters the model. Every threshold you choose becomes one interpretable feature that clauses can reason about, and the name you give it is the name that appears in the printed rules.

The booleanizer creates its four indicator handles once in Init and, in the same method, registers the human-readable name of each feature in the exact order the feature vector will be written. The RSI, two moving averages, and ATR handles are made with the standard MQL5 indicator functions, and then ten names are appended:

   m_hRSI    = iRSI(symbol, tf, rsiPeriod, PRICE_CLOSE);
   m_hMAfast = iMA(symbol, tf, maFast, 0, MODE_EMA, PRICE_CLOSE);
   m_hMAslow = iMA(symbol, tf, maSlow, 0, MODE_EMA, PRICE_CLOSE);
   m_hATR    = iATR(symbol, tf, atrPeriod);

   if(m_hRSI == INVALID_HANDLE || m_hMAfast == INVALID_HANDLE ||
      m_hMAslow == INVALID_HANDLE || m_hATR == INVALID_HANDLE)
     {
      Print("CBooleanizer: failed to create indicator handles");
      return false;
     }

//--- register feature names (order == Build order) --------------
   m_nFeatures = 0;
   ArrayResize(m_names, 0);

//--- RSI family
   AddName("RSI>70");        // overbought
   AddName("RSI<30");        // oversold
   AddName("RSI>50");        // bullish half
//--- price vs moving averages
   AddName("close>MAfast");
   AddName("close>MAslow");
   AddName("MAfast>MAslow"); // fast-above-slow (trend up)
//--- volatility regime
   AddName("ATR-high");      // ATR above its lookback median
//--- candle / return features
   AddName("prevUp");        // previous bar closed up
   AddName("bigBody");       // |body| > half the range
   AddName("higherHigh");    // this bar's high > previous high

AddName grows the names array by one and stores the label, keeping a running feature count. The comment "order == Build order" is a real constraint, not decoration: the write order in Build must match this registration exactly, or the printed rules would carry the wrong labels while the numbers stayed correct, which is a silent and confusing failure. Keeping the two lists adjacent in the source is the guard against that drift.

The Build method fills the feature vector for a given bar. It copies the indicator values it needs, computes a couple of candle statistics, and writes a clean 0 or 1 per feature. The comparisons are the whole content of the model's view of the market:

//--- RSI family
   x[k++] = (rsi[0] > 70.0)          ? 1 : 0;   // RSI>70
   x[k++] = (rsi[0] < 30.0)          ? 1 : 0;   // RSI<30
   x[k++] = (rsi[0] > 50.0)          ? 1 : 0;   // RSI>50
//--- price vs MAs
   x[k++] = (close1 > maF[0])        ? 1 : 0;   // close>MAfast
   x[k++] = (close1 > maS[0])        ? 1 : 0;   // close>MAslow
   x[k++] = (maF[0] > maS[0])        ? 1 : 0;   // MAfast>MAslow
//--- volatility
   x[k++] = (atr[0] > atrMedian)     ? 1 : 0;   // ATR-high
//--- candle / return
   x[k++] = (close2 > open2)         ? 1 : 0;   // prevUp
   x[k++] = (body1 > 0.5 * range1)   ? 1 : 0;   // bigBody
   x[k++] = (high1 > high2)          ? 1 : 0;   // higherHigh

Two design choices are worth calling out. The "ATR-high" bit compares the current ATR to the median ATR of a lookback window rather than to a fixed level, so it means "volatility is high relative to recent history" and stays meaningful across instruments with different pip scales. That median is computed just above this excerpt by copying the last hundred ATR values, sorting them, and taking the middle one. And the machine is never handed the negations here: it appends the "NOT" of every feature internally, so the booleanizer only emits the ten positive bits and their names. That is why a printed rule can say "NOT ATR-high" even though the booleanizer never wrote such a bit.


Building the Dataset and Labeling by Forward Return

Features answer "what does this bar look like". A supervised classifier also needs a label answering "what came next". The training script TM_Train.mq5 builds both together in BuildDataset, walking history from old to recent so the rows come out in chronological order. For each bar it booleanizes the features and then labels the bar by its forward return over a fixed horizon.

   for(int shift = startShift; shift >= endShift; shift--)
     {
      if(!bz.Build(shift, xi))
         continue;
      if(CopyBuffer(atrHandle, 0, shift, 1, atrBuf) < 1)
         continue;
      double atr = atrBuf[0];
      if(atr <= 0.0)
         continue;

      double closeNow = iClose(_Symbol, _Period, shift);
      double closeFwd = iClose(_Symbol, _Period, shift - InpHorizon);
      if(closeNow == 0.0 || closeFwd == 0.0)
         continue;

      double move   = closeFwd - closeNow;
      double thresh = InpThreshATR * atr;

      int label = CLS_FLAT;
      if(move >  thresh)
         label = CLS_BUY;
      else
         if(move < -thresh)
            label = CLS_SELL;

      int base = rows * nFeatures;
      for(int f = 0; f < nFeatures; f++)
         X[base + f] = xi[f];
      y[rows] = label;
      rows++;
     }

The label is scaled to volatility, not to a fixed pip distance. A bar is BUY only if the close five bars later is more than half an ATR above the current close, SELL if it is more than half an ATR below, and flat otherwise. Using ATR rather than a fixed threshold keeps the three classes roughly balanced whether the market is quiet or fast, which matters because a wildly imbalanced label set would let the machine score well by always predicting the majority class without learning anything.

The startShift and endShift bounds keep the walk honest at both ends. The oldest usable bar leaves the horizon of future bars needed for its label, and the most recent usable bar is the newest one that still has a full horizon ahead of it. Bars whose indicators or forward close are not available are skipped with continue, so the final row count can be smaller than the requested history. After the loop the dataset is trimmed to the rows actually filled.

The dataset is then split chronologically, not shuffled, because this is a time series and testing on the future is the only honest test. The first fraction of rows trains the model and the remaining, later rows test it, so the test bars are genuinely unseen dates rather than randomly interleaved neighbors of the training bars.


Training on Market Data and Reading the Rules

With the dataset built, TM_Train.mq5 trains the machine and prints the rules. Run on EURUSD H1, the training log reports the class distribution, the accuracy, and then dumps the learned rules. Here is the honest result:

dataset: 3995 rows total  (train=2796, test=1199)
  train: 2796 rows  | flat=879 (31.4%)  sell=968 (34.6%)  buy=949 (33.9%)
  test : 1199 rows  | flat=379 (31.6%)  sell=459 (38.3%)  buy=361 (30.1%)
---------------------------------------------
train accuracy : 0.402
test  accuracy : 0.346
test  baseline : 0.383  (always predict majority class 1)
edge over base : -0.037

The number to read carefully is the last one. On out-of-sample bars the machine scores 0.346, which is below the 0.383 you would get by always guessing the majority class. With this feature set and this labeling, the machine finds no predictive edge over the next five bars on EURUSD H1. That is stated plainly because inventing a better number would be worthless to you and dishonest. A three-class direction problem on an efficient hourly pair, using only these ten thresholds, is genuinely hard, and the machine is not going to manufacture signal that is not there.

What the machine does provide, that a black box cannot, is a readable account of the structure it did settle on. The rule dump for the BUY class shows it assembling coherent logic even where that logic lacks forward power:

---- class BUY : learned rules ----
   clause  6 (+) : bigBody AND higherHigh
   clause  8 (+) : NOT RSI>70 AND NOT RSI>50 AND NOT close>MAfast AND NOT close>MAslow
   clause  2 (+) : NOT ATR-high AND NOT higherHigh

This is the real value of interpretability in a losing configuration. The machine tells you what it keyed on, so you can see that "big body plus higher high" and a cluster of oversold conditions are what it associated with a forward move up. If you disagree, or want to test different thresholds, or add a feature it clearly lacks, you are editing readable logic rather than poking at opaque weights. With a neural network at the same accuracy you would have no idea what it decided, and no principled place to start improving it.

The dump is produced by DumpClass, which walks the clauses of one class, skips the empty ones, tags each with its polarity, and prints the rule text from the clause's own ToString. It is a thin wrapper over the interpretability that already lives in the clause:

//+------------------------------------------------------------------+
//| Dump learned clauses for one class                               |
//+------------------------------------------------------------------+
void DumpClass(CTsetlinMachine &tm, int k, string clsName, const string &names[])
  {
   PrintFormat("  ---- class %s : learned rules ----", clsName);
   int shown = 0;
   for(int c = 0; c < tm.ClausesPerClass() && shown < InpMaxRulesShown; c++)
     {
      CClause cl;
      if(!tm.GetClause(k, c, cl))
         continue;
      if(cl.IncludedCount() == 0)
         continue;          // skip empty clauses
      string pol = tm.ClauseIsPositive(c) ? "(+)" : "(-)";
      PrintFormat("     clause %2d %s : %s", c, pol, cl.ToString(names));
      shown++;
     }
   if(shown == 0)
      PrintFormat("     (no non-empty clauses)");
  }

The honest read of this experiment is not that the Tsetlin Machine failed. It is that the feature set and the five-bar direction target do not carry an edge on this instrument, and the machine reported that faithfully instead of overfitting a story. The path forward is better features and better labels, and the interpretability is exactly what makes that iteration tractable.


Saving the Model and a White-Box Panel

Because the trained model is nothing but integer automaton states plus a few shape parameters, it saves to a plain CSV. There is no ONNX export and no binary serialization; the entire model is text. The header line records the machine's shape, and each following line is one clause's states. Loading it back reconstructs the machine bit for bit, verified by a round-trip in the Python reference where both the states and the predictions matched exactly.

The save and load functions take a flag to use the shared Common folder rather than the sandboxed terminal folder, because the Strategy Tester runs in an isolated agent directory and cannot see the terminal's own Files folder. A model that any tester-driven program must load has to live in Common. Save writes the header, then one line per clause holding its raw automaton states:

//+------------------------------------------------------------------+
//| Save the trained machine to a readable CSV in Files\             |
//|                                                                  |
//|  Line 1 : header  "TM,<nClasses>,<nFeatures>,<clausesPerClass>,  |
//|                     <nStates>,<T>,<s>"                           |
//|  then one line per clause: "<k>,<c>,<state0>,<state1>,..."       |
//|  The states are the raw automaton counters (integers) - loading  |
//|  them back reproduces the model bit-for-bit. Nothing else is     |
//|  needed because inference never touches the hyperparameters s/T. |
//+------------------------------------------------------------------+
bool CTsetlinMachine::Save(string filename, bool common) const
  {
   int flags = FILE_WRITE | FILE_TXT | FILE_ANSI;
   if(common)
      flags |= FILE_COMMON;
   int h = FileOpen(filename, flags);
   if(h == INVALID_HANDLE)
     {
      PrintFormat("CTsetlinMachine::Save - cannot open %s (err %d)",
                  filename, GetLastError());
      return false;
     }

   FileWriteString(h, StringFormat("TM,%d,%d,%d,%d,%d,%.6f\n",
                                   m_nClasses, m_nFeatures, m_clausesPerClass,
                                   m_nStates, m_T, m_s));

   int nLit = 2 * m_nFeatures;
   for(int k = 0; k < m_nClasses; k++)
      for(int c = 0; c < m_clausesPerClass; c++)
        {
         string line = StringFormat("%d,%d", k, c);
         int idx = Idx(k, c);
         for(int lit = 0; lit < nLit; lit++)
            line += StringFormat(",%d", m_clauses[idx].GetState(lit));
         line += "\n";
         FileWriteString(h, line);
        }

   FileClose(h);
   return true;
  }

Load reverses this exactly. It reads the header, re-initializes the machine to that shape, and then overwrites every automaton state with the stored value. After it returns the machine is ready for Predict with no training, which is what a chart program or an Expert Advisor needs at start-up. The load also guards against a truncated or malformed file so a bad model does not silently produce garbage predictions:

//--- header
   string hdr = FileReadString(h);
   string tok[];
   int n = StringSplit(hdr, ',', tok);
   if(n < 7 || tok[0] != "TM")
     {
      Print("CTsetlinMachine::Load - bad header");
      FileClose(h);
      return false;
     }
   int    nClasses = (int)StringToInteger(tok[1]);
   int    nFeat    = (int)StringToInteger(tok[2]);
   int    cpc      = (int)StringToInteger(tok[3]);
   int    nStates  = (int)StringToInteger(tok[4]);
   int    T        = (int)StringToInteger(tok[5]);
   double s        = StringToDouble(tok[6]);

   Init(nClasses, nFeat, cpc, nStates, T, s, m_seed);

The payoff of a readable model is the indicator TM_ActiveRules.mq5. In OnInit it initializes a booleanizer, loads the saved model, and checks that the model's feature count matches the booleanizer's, refusing to run on a mismatch so it never decodes bars with the wrong feature layout. On each closed bar it booleanizes the current market and asks which clauses fire. It draws a light panel with the features that are ON, the count of firing rules per class, the readable rules currently active, and a conviction meter.

The conviction meter is the gap between the leading class and the runner-up, normalized by the clause count. It measures how cleanly the active logic points one way versus being scattered across classes. It is a measure of structure clarity, and it is deliberately not a price forecast:

//--- conviction = leader's gap over the runner-up, normalized.
//--- Measures how cleanly the firing logic points one way vs being
//--- scattered. NOT a price forecast - it is structure clarity.
   int lead = buyNet;
   if(sellNet > lead)
      lead = sellNet;
   if(flatNet > lead)
      lead = flatNet;
   int second = -100000;
   if(buyNet  != lead && buyNet  > second)
      second = buyNet;
   if(sellNet != lead && sellNet > second)
      second = sellNet;
   if(flatNet != lead && flatNet > second)
      second = flatNet;
   int gap = lead - second;
   double conviction = 100.0 * (double)gap / (double)g_tm.ClausesPerClass();

The panel refreshes once per new bar. OnCalculate compares the time of the last bar to the one it drew last, and only rebuilds the panel when a new bar has formed, so the indicator does no work on every tick. That new-bar guard is what keeps a rule-decoding panel cheap enough to leave running on a live chart.

Attach the indicator to a chart and it turns the market into a running commentary in the machine's own logic. When many clauses of one class fire together and few of the others do, the conviction is high and the panel is telling you the current bar strongly matches one learned pattern. When the firing is scattered, conviction falls, and the panel is telling you the bar does not clearly match anything the machine learned. This is the same information a neural network holds internally and never shares. Here it is on screen, in words.

A left-to-right pipeline: price chart and indicators feed the booleanizer, which produces a boolean feature vector, which the trained machine turns into class votes, which the panel renders as readable firing rules and a conviction meter

Fig. 7. The end-to-end path: indicators to booleanizer to machine to readable rules and the panel. Training runs the same left half, then labels each bar by its forward return.


Conclusion

We built a Tsetlin Machine from scratch in pure MQL5: a multi-class classifier that learns readable AND-rules and runs entirely on integer state counters, with no floating-point math, no gradient, and no external library. It was proven on boolean ground truth, including the non-separable XOR, before it touched a chart, and every result was cross-checked against an independent Python reference that also caught a real bug in the feedback. On EURUSD H1 the classifier found no predictive edge over a five-bar horizon with the given features, and that was reported exactly as it came out. What it did deliver, and what a black box cannot, is a model you can read and a panel that shows its reasoning bar by bar.

What the article leaves you with:

  • A single-header library: CTsetlinAutomaton, CClause, and CTsetlinMachine with Type I and Type II training, argmax voting, and CSV save and load.
  • A booleanizer, CBooleanizer, that turns indicators into named boolean features and appends their negations for the machine.
  • A ground-truth test script that verifies learning on XOR, AND, OR, and a three-class task, and prints the learned rules.
  • A training script that builds a volatility-scaled labeled dataset, fits on historical bars, reports honest out-of-sample accuracy against a majority baseline, dumps the rules in plain language, and saves the model.
  • An indicator that loads a trained model and shows the firing rules and a conviction meter live on the chart.

The natural next step is entirely in your hands and needs no code changes: extend the booleanizer with features you believe carry forward information, relabel the target to match the horizon you actually trade, and read the rules the machine settles on. Because the model is readable, that loop is something you can reason about rather than guess at.

#
Filename
Type
Description
1
TsetlinMachine.mqh
Include
The automaton, clause, and multi-class machine, with training, voting, and CSV save/load
2
Booleanizer.mqh
Include
Turns RSI, moving-average, ATR, and candle indicators into named boolean features
3
TM_Test_GroundTruth.mq5
Script
Verifies learning on XOR, AND, OR, and a three-class task; prints the learned rules
4
TM_Train.mq5
Script
Builds a labeled dataset, fits on historical bars, reports out-of-sample accuracy, dumps rules, saves the model
5
TM_ActiveRules.mq5
Indicator
Loads a trained model and shows the firing rules and conviction meter live on the chart
Attached files |
MQL5.zip (21.26 KB)
Last comments | Go to discussion (1)
Rasoul Mojtahedzadeh
Rasoul Mojtahedzadeh | 14 Aug 2026 at 10:07
This is a nice article for trading research! Thanks for sharing!
How to Detect and Normalize Chart Objects in MQL5 (Part 5): Fibonacci in Focus How to Detect and Normalize Chart Objects in MQL5 (Part 5): Fibonacci in Focus
The article bridges automated placement with manual analysis for the Fibonacci family in MQL5. It scans charts, identifies user Fibonacci objects, and normalizes their level arrays, interaction flags, and visuals per object type while preserving coordinates. With manual-priority enforcement, Expert Advisors can evaluate both human and code-generated tools reliably, without duplicates or runtime indexing issues.
Building a Position Sizing Engine in MQL5 with Multiple Risk Models Building a Position Sizing Engine in MQL5 with Multiple Risk Models
The article presents a position sizing engine for MQL5 Expert Advisors that separates risk policy from lot conversion. Four models—fixed fractional, fixed monetary, ATR-based volatility scaling, and equity-curve scaling—share a CLotConverter that uses OrderCalcProfit() to measure real money per point. A unified CPositionSizer interface exposes CalculateLots(), making model changes straightforward while producing broker-compliant volumes across symbols.
Features of Experts Advisors Features of Experts Advisors
Creation of expert advisors in the MetaTrader trading system has a number of features.
From Novice to Expert: Weekend Gap Size Effect Research Using MQL5 and Python From Novice to Expert: Weekend Gap Size Effect Research Using MQL5 and Python
The article provides a practical research setup for weekend gap analysis: MQL5 extracts precise pip‑based gaps and tracks fills, while Python performs statistical testing and visualization. You will compute fill rates by gap buckets, model fill probability with logistic regression, and assess time-to-fill via Kaplan–Meier curves. All steps are configurable and reproducible for EURUSD, GBPUSD, USDJPY and beyond.