preview
Native Isolation Forest for Execution-Quality Anomaly Detection in MQL5

Native Isolation Forest for Execution-Quality Anomaly Detection in MQL5

MetaTrader 5Machine learning |
126 0
Olamide Daniel Adebayo
Olamide Daniel Adebayo

Introduction

Most of what gets written about anomaly detection in trading circles is aimed at prices: spotting a crash, a spike, a regime break in the market itself. Almost nobody points the same tool at the thing that actually determines whether a strategy survives contact with a real broker - the quality of its own executions. Slippage, fill latency, the spread you actually paid at the moment of the fill, partial fills, quiet requote clusters. These are the variables that decide whether your backtest edge shows up in the live account or gets eaten alive by friction nobody was watching.

This article builds a native Isolation Forest in MQL5 to monitor execution quality. Every time a position opens, the EA captures a small feature vector describing how well the fill went and scores it against a model trained on the EA's own recent execution history. It reacts - pausing entries, widening stops, or halting entirely - the moment the numbers stop looking like anything the strategy has seen before. No ONNX, no ALGLIB, no external DLL. The whole tree-ensemble, from random split selection to path-length scoring, is written in plain MQL5 and runs inside the EA process.

Scope note: this is a monitoring and circuit-breaker layer, not a signal generator. It does not predict price direction and it will not make a mediocre strategy profitable. What it does is protect a strategy that already works from silently degrading because the broker's liquidity, your VPS latency, or market conditions around your fills have quietly shifted for the worse.


Why execution quality needs its own anomaly detector

If you've ever compared a strategy tester report to a live account statement and wondered where the difference came from, you already know the problem. Backtests assume a fixed, well-behaved execution model; live trading gives you a distribution where the tail matters more than the average. A single 40-point slippage event during a news spike, or a run of 2-second fill delays during a liquidity gap, can do more damage than a hundred ordinary trades combined, and it usually happens quietly enough that nobody notices until the equity curve has already bent.

The instinct is to set a hard slippage cap and reject fills beyond it. That works for single-feature problems, but execution quality is genuinely multivariate - a fill can be individually "fine" on slippage, latency, and spread, and still be jointly unusual in a way that a single threshold per feature will never catch. That's the case for an unsupervised, multivariate detector rather than a stack of independent guardrails.

It's worth naming the alternatives rather than skipping straight to the answer. A per-feature z-score misses the jointly unusual case described above. DBSCAN or Local Outlier Factor need a distance metric and a neighborhood-size parameter that's awkward to keep meaningful as the rolling window ages. A One-Class SVM needs a kernel and margin parameter tuned per dataset, and none of the three ships with a native, dependency-free MQL5 implementation the way a random-split tree ensemble does. Isolation Forest fits because it's parameter-light, needs no distance metric or kernel, degrades gracefully on small samples, and is simple to implement natively with no numerical library behind it.

Isolation Forest is a good fit here for a reason easy to lose track of: it doesn't need labeled anomalies to train on, and execution anomalies are exactly the kind of thing you rarely have clean labels for in advance. Scoring takes O(num_trees x height_limit) comparisons per point - roughly 100 x 8, under a thousand comparisons in the default configuration. This is why it can run inside OnTradeTransaction(). This is Big-O, not a measured benchmark: rather than assert a number I can't back up, the EA carries an InpLogTimings input that prints real elapsed milliseconds for scoring and retraining to the Journal on your own hardware - see "How to run and reproduce" for the steps.

A hard slippage cap in your trade-execution code is still worth keeping - it's a different layer catching a different problem. The cap protects one trade from an extreme fill; the forest watches for a pattern across many fills that individually pass every hard check but are, taken together, unlike anything the strategy has seen before. Neither substitutes for the other.


The isolation forest idea, briefly

The core insight behind Isolation Forest, from Liu, Ting, and Zhou's original 2008 paper, is refreshingly different from most anomaly detection approaches. Instead of modeling what "normal" looks like and measuring distance from it, it exploits a structural property of anomalies directly: they are few, and they are different, which means they get isolated from the rest of the data in fewer random partitions than normal points do.

An isolation tree is built by repeatedly picking a random feature and a random split value between that feature's min and max within the current subset, until every point is alone in its own partition or a height limit is reached. Do this many times with different random subsamples and average the path length it takes to isolate a given point across the whole forest. Short average path length means the point was easy to isolate - a likely anomaly. Long average path length means it took a lot of splits to separate it from its neighbors - a normal point sitting in a dense region.

Raw path length alone isn't comparable across different subsample sizes, though - an 8-level path means something different in a 32-point subsample than a 512-point one. That's what the normalization step below is for:

To turn a raw average path length into a usable 0-to-1 anomaly score, it gets normalized against the expected path length of an unsuccessful search in a Binary Search Tree of the same subsample size:

s(x, n) = 2 ^ ( -E(h(x)) / c(n) )

where:
  E(h(x)) = average path length of point x across all trees
  c(n)    = expected unsuccessful-search path length for a sample of size n
          = 2*H(n-1) - (2*(n-1) / n)
  H(i)    ~= ln(i) + 0.5772156649   (the Euler-Mascheroni constant)

Scores close to 1 indicate a strong anomaly, and 0.5 is the textbook reference point from the paper's asymptotic reasoning. In practice that theoretical midpoint is rarely the real working threshold - normal-point scores in a live log cluster somewhere below it, so the effective decision boundary ends up calibrated from data. The validation section below shows this landing in the 0.65-0.70 range for one real log, and the EA ships with InpAnomalyThreshold = 0.68 as a starting default informed by that run - not a universal constant.


Feature engineering: what "execution quality" actually means

The forest is only as useful as the vector it's scoring, so this deserves more thought than it usually gets. For this article the feature vector is deliberately small - five dimensions - because execution logs accumulate slowly relative to price data, and an isolation forest trained on a thin rolling window does better with fewer, well-chosen dimensions than with a large feature set that dilutes the signal.

1. Slippage (points) – absolute distance between the requested price captured just before OrderSend and the confirmed deal price.

2. Fill latency (ms) - true millisecond-resolution elapsed time between issuing the request and the confirming deal transaction, measured with GetTickCount64() rather than TimeCurrent().

3. Spread-at-fill (points) - the live bid/ask spread at the moment of execution, since slippage during a wide-spread moment means something different than the same slippage during a tight one.

4. Volume deviation (%) - the percent difference between requested and filled volume, to catch partial fills. In this version, the requested volume is taken from InpLots. If position sizing becomes dynamic, capture the requested size at request time instead of reading it from a static input.

5. Reject flag - always present as the fifth dimension of the vector, but currently a fixed placeholder: every row that reaches OnTradeTransaction() is by definition a confirmed fill, so this column is hard-coded to 0.0 in the current implementation. It's kept in the contract on purpose - the array shape, the persisted-forest schema, and the CSV header all assume five columns - so that wiring in true requote/rejection events later (via TRADE_TRANSACTION_REQUEST and the result codes) is a drop-in change to one line rather than a breaking schema migration. This is called out explicitly in the scope section below rather than left implicit.

The feature vector is fixed at five features. The contract, persisted model, and CSV schema all assume this shape. Extending it means bumping ISO_FEATURE_COUNT and EXEC_FEATURE_COUNT together and retraining from scratch.

An earlier draft measured elapsed time with TimeCurrent(), second-level resolution only - a genuine bug, since the feature was labeled "ms" while carrying second-level data. GetTickCount64() fixes this with a monotonic ms counter for the session, exactly the scope one request-to-fill measurement needs.

This feature contract is enforced, not assumed. The EA checks three independent sources of truth at OnInit(): the compile-time #defineISO_FEATURE_COUNT, the feature logger header's own EXEC_FEATURE_COUNT, and - if a persisted forest exists on disk - the feature count that forest was actually trained with. Any mismatch between the three is a fatal, loud INIT_FAILED, not a silent fallback:

//+------------------------------------------------------------------+
//| OnInit                                                           |
//+------------------------------------------------------------------+
int OnInit()
  {
//--- three-way feature contract check (compile-time vs. header vs. persisted forest)
   if(EXEC_FEATURE_COUNT != ISO_FEATURE_COUNT)
     {
      Print("FATAL: feature contract mismatch...");
      return(INIT_FAILED);
     }

   g_logger.Init(InpTrainingWindow, "IsoForestExecMonitor\\execution_log.csv");

   string forest_file = "IsoForestExecMonitor\\isoforest_model.bin";
   if(g_forest.LoadFromFile(forest_file))
     {
      if(g_forest.FeatureCount() != ISO_FEATURE_COUNT)
         {
          Print("FATAL: persisted forest feature count mismatch...");
          return(INIT_FAILED);
         }
     }
   // ... EMA handle setup, anomaly-history buffer sizing, INIT_SUCCEEDED ...
  }

A forest trained on four features scoring a five-feature vector produces numbers that look plausible and mean nothing, which is worse than an outright crash - hence the loud failure instead of a silent fallback.


Native architecture: CIsoTree and CIsoForest

The implementation splits cleanly into two classes. CIsoTree stores a single randomized tree as a flat array of nodes rather than a pointer structure - easier to serialize to FILE_COMMON and easier to debug when a tree isn't isolating what you expect.

The normalization constant from the scoring formula is its own small function, reused both globally (in AnomalyScore() below) and locally (the leaf-size correction in PathLength()):

//+------------------------------------------------------------------+
//| IsoAvgPathLength - c(n), average path length of an unsuccessful  |
//| BST search                                                       |
//+------------------------------------------------------------------+
double IsoAvgPathLength(const int n)
  {
   if(n <= 1)
     return(0.0);
   if(n == 2)
     return(1.0);
   double h = MathLog(n - 1) + 0.5772156649015329; // Euler-Mascheroni constant
   return(2.0 * h - (2.0 * (n - 1) / n));
  }

The actual tree construction - random feature selection, random threshold, the recursive split, and every termination case - in full:

//+------------------------------------------------------------------+
//| BuildRecursive - random feature/threshold selection and recursive|
//| split                                                            |
//+------------------------------------------------------------------+
int CIsoTree::BuildRecursive(double &data[][], int &idx[], int n, int depth, int feature_count)
  {
   int node_idx = AddNode();

   if(depth >= m_height_limit || n <= 1)
     {
      m_nodes[node_idx].is_external = true;
      m_nodes[node_idx].size        = n;
      return(node_idx);
     }

   int feat = (int)MathFloor(MathRand() / 32767.0 * feature_count);
   if(feat >= feature_count)
     feat = feature_count - 1;

   double vmin =  DBL_MAX;
   double vmax = -DBL_MAX;
   for(int i = 0; i < n; i++)
     {
      double v = data[idx[i]][feat];
      if(v < vmin) vmin = v;
      if(v > vmax) vmax = v;
     }

   if(vmin == vmax)
     {
      // no separation possible on this feature - treat as external
      m_nodes[node_idx].is_external = true;
      m_nodes[node_idx].size        = n;
      return(node_idx);
     }

   double split_value = vmin + (vmax - vmin) * (MathRand() / 32767.0);

   int left_idx[], right_idx[];
   ArrayResize(left_idx, n);
   ArrayResize(right_idx, n);
   int nl = 0, nr = 0;
   for(int i = 0; i < n; i++)
     {
      if(data[idx[i]][feat] < split_value)
         left_idx[nl++] = idx[i];
      else
         right_idx[nr++] = idx[i];
     }
   ArrayResize(left_idx, nl);
   ArrayResize(right_idx, nr);

   if(nl == 0 || nr == 0)
     {
      m_nodes[node_idx].is_external = true;
      m_nodes[node_idx].size        = n;
      return(node_idx);
     }

   m_nodes[node_idx].split_feature = feat;
   m_nodes[node_idx].split_value   = split_value;

   int left_node  = BuildRecursive(data, left_idx,  nl, depth + 1, feature_count);
   int right_node = BuildRecursive(data, right_idx, nr, depth + 1, feature_count);

   m_nodes[node_idx].left  = left_node;
   m_nodes[node_idx].right = right_node;

   return(node_idx);
  }


Fig. 1. End-to-end flow from a confirmed fill through feature extraction, native scoring, and circuit-breaker actions.

Each node in the flat array is either internal (holding a split feature index and split threshold) or external - a leaf. The recursive builder stops early when depth hits the height limit (ceil(log2(subsample_size)), matching the paper's recommendation), when a subset can no longer be usefully split, or - the case shown in full below - when a subset lands on a feature where every remaining point shares the same value, leaving nothing to split on.

Path length for a query point is a straightforward tree walk, with one detail that's easy to skip and shouldn't be: when the walk lands on an external node holding more than one point (because it hit the height limit before fully separating them), the raw depth undercounts how "normal" that point really is. The fix is to add the c(size) correction for that leaf's remaining point count on top of the depth reached.

//+------------------------------------------------------------------+
//| PathLength - root-to-leaf depth plus the c(size) leaf correction |
//+------------------------------------------------------------------+
double CIsoTree::PathLength(const double &x[]) const
  {
   int node  = 0;
   int depth = 0;
   while(true)
     {
      if(m_nodes[node].is_external)
         return(depth + IsoAvgPathLength(m_nodes[node].size));
      int feat = m_nodes[node].split_feature;
      node = (x[feat] < m_nodes[node].split_value) ? m_nodes[node].left : m_nodes[node].right;
      depth++;
     }
  }

CIsoForest owns an array of trees, handles subsampling without replacement for each tree (a partial Fisher-Yates shuffle keeps this O(subsample_size) rather than O(n) per tree), averages path lengths across the ensemble at scoring time, and serializes the whole thing to FILE_COMMON so a trained forest survives an EA restart and is visible to any process reading the shared terminal common data folder, including Strategy Tester agents. One caveat: parallel optimization agents writing to the same model filename simultaneously will race, since MQL5 gives no cross-process file locking - give each optimization pass its own filename, or disable retraining in the tester.

The scoring formula, in code:

//+------------------------------------------------------------------+
//| AnomalyScore - s(x, n) = 2^(-E(h(x))/c(n))                       |
//+------------------------------------------------------------------+
double CIsoForest::AnomalyScore(const double &x[]) const
  {
   if(!m_trained || m_num_trees == 0)
     return(0.5); // no opinion yet - see "what happens when there is no model" below

   double total_path = 0.0;
   for(int t = 0; t < m_num_trees; t++)
     total_path += m_trees[t].PathLength(x);

   double avg_path = total_path / m_num_trees;
   double c_n      = IsoAvgPathLength(m_subsample_size);
   if(c_n <= 0.0)
     return(0.5);

   return(MathPow(2.0, -avg_path / c_n)); // s(x, n) = 2^(-E(h(x))/c(n))
  }

Every tree contributes one PathLength() call, normalized against the same IsoAvgPathLength() used for the internal leaf correction - exactly the formula from the isolation-forest section, nothing hidden between the math and the code.

Default settings use 100 trees and a subsample size of 256, close to the values the original paper found stable across datasets - though the validation section below shows this isn't blindly transferable to a small execution log without checking.

The forest is serialized as flat binary (FileWriteInteger/FileWriteDouble) for performance, rather than JSON or CSV - with a few hundred nodes per tree across 100 trees, that's tens of thousands of records, and binary I/O avoids the tokenizing and float round-trip cost a text format would add on every restart. The drawback is reduced human readability without a small helper script.


Wiring it into the EA: event-driven scoring and the circuit breaker

Scoring happens exclusively inside OnTradeTransaction(), filtered to TRADE_TRANSACTION_DEAL_ADD with DEAL_ENTRY_IN - polling every tick would be wasteful and semantically wrong, since there's nothing to score between fills. Request price and timestamp are captured right before trade.Buy()/trade.Sell(), so slippage and latency are computed against what was actually asked for.

On a confirmed fill, the monitor does five things in sequence:

1. Build the five-feature vector from the deal and the captured request context.

2. Push it into the rolling CExecFeatureLogger buffer and append it to the FILE_COMMON CSV log.

3. If a forest is already trained, score the vector and compare against the threshold.

4. On an anomaly: start a cool-down window that pauses new entries, optionally widen the stops on any open position, and update a rolling anomaly-rate counter.

5. If that rolling rate crosses a secondary threshold, treat it as an execution-quality regime break and halt trading outright rather than just cooling down.

Cool-down for an isolated bad fill, hard halt for a sustained pattern - because one anomalous fill during a news spike is normal and shouldn't stop the strategy, but a sustained run usually means something changed on the broker or connectivity side that no amount of per-trade risk management fixes. The right response there is to stop and investigate, not keep widening stops indefinitely.

//+------------------------------------------------------------------+
//| HandleAnomaly                                                    |
//+------------------------------------------------------------------+
void HandleAnomaly(double score)
  {
   g_cooldown_bars_left = InpCooldownBars;
   Print("Execution-quality anomaly detected, score=", DoubleToString(score, 4),
         " - entries paused for ", InpCooldownBars, " bars.");

   if(InpWidenStopsOnAnom)
      WidenOpenStops();

   double rate = CurrentAnomalyRate();
   if(rate >= InpHaltAnomalyRate)
     {
      g_trading_halted = true;
      Print("Execution-quality regime break - trading halted.");
     }
  }

Retraining is handled the same way as scoring - natively, on a rolling window, with no external dependency. The exact conditions that gate it, and the full training code, are in the "Implementation details" section below rather than repeated here.


Inputs at a glance

Every input referenced above and below, in one place, since they're otherwise scattered across several sections:

Input
Default
Purpose
InpFastEMA / InpSlowEMA
12 / 48
Host EMA-cross entry signal
InpLots
0.10
Fixed lot size; also the "requested volume" baseline
InpStopLossPts / InpTakeProfitPts
400 / 800
Host strategy SL/TP distance
InpTrainingWindow
500
Rolling fills required before first train / used per retrain
InpRetrainEveryTrades
100
Fills between retrains once a model exists
InpNumTrees
100
Trees per forest
InpSubsampleSize
256
Rows sampled per tree (clamped to available rows)
InpAnomalyThreshold
0.68
Score cutoff, calibrated offline (see Validation)
InpCooldownBars
10
Entry pause length after a flagged anomaly
InpHaltAnomalyRate
0.35
Rolling anomaly rate that triggers a full halt
InpHaltLookbackTrades
30
Window the halt rate is computed over
InpWidenStopsOnAnom / InpWidenStopMultiplier
true / 1.5
Whether/how much to widen open stops on an anomaly
InpLogTimings
false
Print real scoring/retrain elapsed ms to the Journal


Edge cases and pitfalls

A few things bit me while building this, and they're worth naming rather than glossing over.

Cold start. The forest can't score anything until it has enough history to train on - you cannot detect an anomaly relative to a baseline that doesn't exist yet. The EA runs pass-through, log-only, until InpTrainingWindow fills accumulate. Don't shrink this window to get scoring active sooner; a forest trained on 40 executions will overfit that noise and flag things that are perfectly normal a week later.

Constant features stall the tree, not the forest. Early in testing, on a demo account with a fixed spread and no requotes, the spread-at-fill feature was identical across the entire training window. Individual trees kept hitting the "vmin == vmax" branch on that feature and terminating early on it, which is exactly correct behavior - it just looked alarming in the logs the first time I saw a tree with unusually few nodes. The forest as a whole still worked fine because the other four features still had variance to split on.

Subsample size larger than available history. If InpSubsampleSize is set larger than the actual rolling window, training clamps it down to n_rows rather than failing, but that silently changes the effective height limit too, since it's derived from the subsample size. Worth logging the effective subsample size used at retrain time rather than assuming the configured one was honored.

GetTickCount64() is session-relative, not wall-clock. Since the latency fix, the feature is a genuine millisecond value, but it's measured against a counter that resets on terminal restart and means nothing compared across two different sessions or two different machines. That's fine for this use case - request and fill always happen inside one running session - but if you ever export latency numbers to compare against another EA's log or another broker's session, remember they're not directly comparable timestamps, just elapsed durations within their own run.

Anomaly frequency drifts with market volatility, not just execution quality. During genuinely volatile sessions, spreads widen and slippage increases for everyone - that's not necessarily an execution problem, it's the market being the market. The rolling retrain cadence helps here because the baseline adapts, but if you trade through scheduled high-impact news, expect a temporary bump in flagged anomalies that isn't a broker issue. Filtering out known news windows from the halt logic (while still logging them) is a reasonable refinement beyond what's shown here.

Retrain cost, same caveat as above. A 100-tree build on a 500-row window runs inside OnTradeTransaction() right after scoring - turn on InpLogTimings before pushing the tree count or training window meaningfully higher than the defaults.


Validation: synthetic anomalies and honest tuning trade-offs

Because there's no natural ground truth for "this fill was anomalous" in a live log, validation has to start synthetic. The Python companion script takes a real execution log exported from the EA's CSV, injects a known contamination rate of artificial slippage spikes and latency bursts with a ground-truth label attached, and checks whether both the native scoring math and a reference scikit-learn IsolationForest recover those injected points.

CSV schema. CExecFeatureLogger::LogToCsv() writes one header row followed by one row per confirmed fill, in this exact column order:

time,slippage_pts,latency_ms,spread_pts,volume_dev_pct,reject_flag,anomaly_score,is_anomaly
2026.03.11 09:14:02,3.20,142.0,18.00,0.000,0,0.4821,0
2026.03.11 09:19:47,2.80,138.0,18.00,0.000,0,0.4695,0
2026.03.11 10:02:15,41.60,2380.0,54.00,0.000,0,0.8112,1

The last row is the shape of a genuine anomaly in this schema - large simultaneous jumps in slippage and latency, coinciding with a widened spread, scoring well above the calibrated threshold.

Exact synthetic injection scheme. inject_synthetic_anomalies() in the companion script uses numpy.random.default_rng(seed=42) for reproducibility. Given a contamination rate (5% in the run behind Fig. 3 and Fig. 4), it selects ceil(len(df) * contamination) row indices uniformly at random without replacement, then perturbs exactly two of the five columns on those rows: slippage_pts is increased by a uniform draw in [15, 40] points, and latency_ms is increased by a uniform draw in [800, 2500] ms. Spread, volume deviation, and reject flag are left untouched on injected rows - the synthetic anomaly is specifically a slippage-and-latency shock, which mirrors the kind of event a liquidity gap or a broker-side stall actually produces. A ground_truth_anomaly column (1 for injected rows, 0 otherwise) is what precision/recall/F1 are computed against:

def inject_synthetic_anomalies(df, contamination=0.05, seed=42):
    """Injects synthetic slippage/latency spikes with a ground-truth label,
    used only for precision/recall validation - never for training the
    native runtime model."""
    rng = np.random.default_rng(seed)
    df = df.copy()
    df["ground_truth_anomaly"] = 0

    n_inject = max(1, int(len(df) * contamination))
    inject_idx = rng.choice(df.index, size=n_inject, replace=False)

    df.loc[inject_idx, "slippage_pts"] += rng.uniform(15, 40, size=n_inject)
    df.loc[inject_idx, "latency_ms"] += rng.uniform(800, 2500, size=n_inject)
    df.loc[inject_idx, "ground_truth_anomaly"] = 1

    return df

Reference model parameters. The scikit-learn cross-check uses sklearn.ensemble.IsolationForest(n_estimators=n_trees, max_samples=subsample_size, contamination=contamination, random_state=42). model.score_samples() is negated to put it on the same "higher means more anomalous" orientation as the native score, and model.predict() == -1 gives the binary anomaly call. F1 is sklearn.metrics.f1_score(ground_truth, predictions) - standard harmonic mean of precision and recall, computed directly against the injected labels, with no smoothing or adjustment:

def reference_isolation_forest(df, n_estimators, max_samples, contamination):
    """scikit-learn reference model, used only to sanity-check the native
    MQL5 scoring math - not used for any live trading decision."""
    model = IsolationForest(
        n_estimators=n_estimators,
        max_samples=max_samples,
        contamination=contamination,
        random_state=42,
    )
    model.fit(df[FEATURE_COLS])
    scores = -model.score_samples(df[FEATURE_COLS])
    preds = (model.predict(df[FEATURE_COLS]) == -1).astype(int)
    return scores, preds

How native and sklearn scores are compared. This is a sanity check, not a claim of numerical equivalence - the two implementations use different random splits by construction, so exact score-for-score agreement was never the goal. What's checked is agreement in rank: whether the same rows land in the top-contamination-fraction by score under both implementations, and whether the shape of the score distribution (unimodal normal cluster, separated anomaly tail) looks structurally similar. Consistently divergent rankings between the two would indicate a bug in the native path; the runs behind Fig. 3 showed the expected agreement, which is why the native distribution is what's plotted rather than the sklearn one.

Fig. 2. A genuinely anomalous fill sits far from the normal cluster and gets isolated in very few random splits; normal fills packed together need many more splits to separate.

Run against a real 900-fill demo-account log with a 5% synthetic contamination rate, the score distribution separates cleanly enough to justify a threshold in the 0.65-0.70 range - the calibration behind the 0.68 default mentioned earlier.

Fig. 3. Score distribution for normal versus synthetically injected anomalous fills, with the calibrated threshold marked.

The part worth being upfront about, since it's easy to gloss over in an article like this: Isolation Forest's performance on a dataset this small is noticeably sensitive to tree count and subsample size, and the relationship isn't the clean "bigger is always better" story you'd hope for. Running the sensitivity sweep across four tree counts and four subsample sizes against the same injected ground truth gives a result that's more of a plateau with a soft peak than a monotonic climb.

Fig. 4. F1 score against injected ground-truth anomalies across tree count and subsample size. The peak sits around 150 trees with a subsample of 256 - larger subsamples past that point actually cost a little accuracy on this dataset.

The takeaway isn't "use 150 trees and 256 samples" as a universal rule - it's that this grid should be re-run against your own execution log rather than assumed, especially if your typical trade frequency gives you a smaller or larger rolling window than the 500-fill default used here. A subsample size close to your entire training window stops giving the forest enough diversity across trees to average over, which is a real, checkable failure mode rather than a theoretical concern.


Implementation details

This section exists to close a specific gap: everything above describes behavior, but a reader trying to actually reproduce this needs to see the concrete formats and decision rules, not just the narrative around them.

Tree node format. Each node is a fixed-size struct stored in a flat, pre-allocated array rather than a linked/pointer structure:

//+------------------------------------------------------------------+
//| IsoNode - single node of an isolation tree                       |
//+------------------------------------------------------------------+
struct IsoNode
  {
   int        left;        // index of left child, -1 if none
   int        right;        // index of right child, -1 if none
   int        split_feature; // feature index used for the split
   double   split_value;   // threshold value
   int        size;        // points reaching this node (external nodes only)
   bool      is_external;  // true = leaf/external node
  };

MQL5 arrays and types. CIsoTree pre-allocates its node array in ISO_MAX_NODES_PER_TREE (4096) chunks, growing with ArrayResize() only if a tree needs more. int child indices instead of object references make the tree trivially serializable - no pointers to chase, just fixed-width records in sequence.

How the dataset is stored. CExecFeatureLogger holds a fixed-capacity 2D array (double m_buffer[][EXEC_FEATURE_COUNT]) as a circular buffer. One MQL5 trap: a local 2D array needs an explicit size for every dimension past the first (double data[][EXEC_FEATURE_COUNT];); the bracket-only form that's valid in a reference parameter compiles as "invalid index value" here.

The class in full, since it's one of the three core files and everything above about the rolling window depends on it:

//+------------------------------------------------------------------+
//| CExecFeatureLogger - rolling execution-quality feature buffer    |
//+------------------------------------------------------------------+
class CExecFeatureLogger
  {
private:
   double           m_buffer[][EXEC_FEATURE_COUNT];
   int           m_capacity;
   int           m_count;
   int           m_write_pos;
   string       m_csv_filename;

public:
              CExecFeatureLogger(void);
   void           Init(int capacity, string csv_filename);
   void           Push(const double &features[]);
   int            Count(void) const { return(m_count); }
   bool          GetMatrix(double &out[][]) const;
   void           LogToCsv(const double &features[], const double anomaly_score, const bool is_anomaly);
  };

void CExecFeatureLogger::Init(int capacity, string csv_filename)
  {
   m_capacity      = capacity;
   m_csv_filename = csv_filename;
   ArrayResize(m_buffer, m_capacity);
   m_count     = 0;
   m_write_pos = 0;
  }

void CExecFeatureLogger::Push(const double &features[])
  {
   for(int f = 0; f < EXEC_FEATURE_COUNT; f++)
     m_buffer[m_write_pos][f] = features[f];

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

bool CExecFeatureLogger::GetMatrix(double &out[][]) const
  {
   if(m_count < 1)
     return(false);

   ArrayResize(out, m_count);
   int start = (m_write_pos - m_count + m_capacity) % m_capacity;
   for(int i = 0; i < m_count; i++)
     {
      int src = (start + i) % m_capacity;
      for(int f = 0; f < EXEC_FEATURE_COUNT; f++)
         out[i][f] = m_buffer[src][f];
     }
   return(true);
  }

void CExecFeatureLogger::LogToCsv(const double &features[], const double anomaly_score, const bool is_anomaly)
  {
   bool exists = FileIsExist(m_csv_filename, FILE_COMMON);
   int handle = FileOpen(m_csv_filename, FILE_READ | FILE_WRITE | FILE_CSV | FILE_COMMON, ',');
   if(handle == INVALID_HANDLE)
     {
      Print("CExecFeatureLogger::LogToCsv - failed to open ", m_csv_filename, " err=", GetLastError());
      return;
     }

   FileSeek(handle, 0, SEEK_END);

   if(!exists)
     FileWrite(handle, "time", "slippage_pts", "latency_ms", "spread_pts", "volume_dev_pct", "reject_flag", "anomaly_score", "is_anomaly");

   FileWrite(handle,
                TimeToString(TimeCurrent(), TIME_DATE | TIME_SECONDS),
                DoubleToString(features[0], 2), DoubleToString(features[1], 1), DoubleToString(features[2], 2),
                DoubleToString(features[3], 3), DoubleToString(features[4], 0),
                DoubleToString(anomaly_score, 4), is_anomaly ? "1" : "0");

   FileClose(handle);
  }

GetMatrix() reconstructs a dense, chronologically-ordered matrix from the ring buffer on demand, so the rolling window actually rolls (oldest fills silently age out) rather than an ever-growing log kept entirely in memory.

How tree depth is calculated. The height limit for a tree is ceil(log2(subsample_size)), computed once per Train() call and passed into every tree's Build(). This matches the original paper's reasoning: since a fully random binary tree over n points has expected depth around log2(n), any point still unseparated past that depth is behaviorally "normal enough" that further splitting adds little discriminative value, and capping depth there keeps the average tree shallow and fast to build and query.

How constant features are handled. Shown in full in BuildRecursive() above - the same vmin == vmax check applies uniformly regardless of which or how many features happen to be constant in a given subset; the builder doesn't special-case any particular feature index.

How the threshold is calculated. There is no closed-form threshold derived from the math - InpAnomalyThreshold is a plain input the EA compares the live score against. The 0.68 default is not computed by the EA at runtime; it's a value chosen offline from the Python validation script's score-distribution analysis on one execution log, then hardcoded as the shipped default. Automatic, in-EA threshold calibration (for example, setting it dynamically from a percentile of the rolling score history) is explicitly not implemented here - see "What this implementation does not cover" below.

When retrain is allowed or prohibited. MaybeRetrainForest() requires two conditions together: at least InpTrainingWindow fills already logged (so a retrain never runs on a starved sample), and either the forest has never been trained before, or at least InpRetrainEveryTrades new fills have arrived since the last retrain. There is no explicit lock against retraining mid-position or during a halt - retraining is a pure function of the logged data and doesn't touch open positions, so it's safe to run regardless of the EA's current halted/cooldown state, and doing so is actually necessary for the baseline to recover once conditions normalize.

The training path has only been described in words so far - here it is in full, alongside the EA-side caller that decides when to invoke it:

//+------------------------------------------------------------------+
//| Train                                                            |
//+------------------------------------------------------------------+
bool CIsoForest::Train(double &data[][], int n_rows, int feature_count, int num_trees, int subsample_size)
  {
   if(n_rows < 4 || feature_count < 1 || num_trees < 1)
     {
      Print("CIsoForest::Train - insufficient data: n_rows=", n_rows, " feature_count=", feature_count);
      return(false);
     }

   m_feature_count  = feature_count;
   m_num_trees      = num_trees;
   m_subsample_size = MathMin(subsample_size, n_rows); // effective subsample size, clamped to available rows

   int height_limit = (int)MathCeil(MathLog(m_subsample_size) / MathLog(2.0));

   ArrayResize(m_trees, m_num_trees);

   for(int t = 0; t < m_num_trees; t++)
     {
      //--- random subsample without replacement (partial Fisher-Yates)
      int pool[];
      ArrayResize(pool, n_rows);
      for(int i = 0; i < n_rows; i++)
         pool[i] = i;

      int sample_idx[];
      ArrayResize(sample_idx, m_subsample_size);
      int pool_size = n_rows;
      for(int i = 0; i < m_subsample_size; i++)
         {
         int r = (int)MathFloor(MathRand() / 32767.0 * pool_size);
         if(r >= pool_size) r = pool_size - 1;
         sample_idx[i] = pool[r];
         pool[r] = pool[pool_size - 1];
         pool_size--;
       }

      m_trees[t].Build(data, sample_idx, m_subsample_size, feature_count, height_limit);
     }

   m_trained = true; // only ever set here, on a completed pass over every tree
   return(true);
  }
//+------------------------------------------------------------------+
//| MaybeRetrainForest                                               |
//+------------------------------------------------------------------+
void MaybeRetrainForest(void)
  {
   if(g_trades_since_retrain < InpRetrainEveryTrades && g_forest.IsTrained())
     return;
   if(g_logger.Count() < InpTrainingWindow)
     return; // not enough rolling history yet

   double data[][EXEC_FEATURE_COUNT];
   if(!g_logger.GetMatrix(data))
     return;

   ulong t0 = GetTickCount64();
   bool  ok = g_forest.Train(data, ArrayRange(data, 0), ISO_FEATURE_COUNT, InpNumTrees, InpSubsampleSize);
   ulong elapsed_ms = GetTickCount64() - t0;

   if(ok)
     {
      g_trades_since_retrain = 0;
      Print("IsoForest: retrained natively on ", ArrayRange(data, 0), " executions",
           " (effective subsample=", MathMin(InpSubsampleSize, ArrayRange(data, 0)), ").");
      if(InpLogTimings)
        Print("IsoForest: Train() took ", elapsed_ms, " ms for ", InpNumTrees, " trees.");
     }
  }

GetMatrix() is what turns the circular buffer back into the dense, ordered matrix Train() consumes; the effective subsample size is MathMin(InpSubsampleSize, n_rows) both inside Train() and in the log line that reports it, so the two can never silently disagree; and m_trained is set to true in exactly one place - the last line of a fully completed Train() call, after every tree has been built.

What happens when there is no model. Before the first successful Train() call, IsTrained() returns false, AnomalyScore() is never invoked, and every fill is logged with a placeholder anomaly_score = 0.5 and is_anomaly = false. This is a genuine "no opinion yet" state - the EA is explicitly observe-only during this window, matching the cold-start pitfall named above.

How request context and the fill event are synchronized. RecordPendingRequest() runs synchronously right before trade.Buy()/trade.Sell(), storing price and a GetTickCount64() snapshot in two globals that OnTradeTransaction() reads back on the confirming deal. This is safe for the single-position host strategy used here, and explicitly not safe for a strategy firing multiple concurrent orders, where a second request would overwrite the first's context before its fill confirms (see scope section below).

Two complete, unedited functions from the source, chosen because together they cover most of what a reader needs to see end-to-end: the full fill-scoring pipeline, and the full persistence round-trip.

//+------------------------------------------------------------------+
//| OnTradeTransaction                                               |
//+------------------------------------------------------------------+
void OnTradeTransaction(const MqlTradeTransaction &trans,
                            const MqlTradeRequest &request,
                            const MqlTradeResult &result)
  {
   if(trans.type != TRADE_TRANSACTION_DEAL_ADD)
     return;
   if(!HistoryDealSelect(trans.deal))
     return;
   if(HistoryDealGetInteger(trans.deal, DEAL_ENTRY) != DEAL_ENTRY_IN)
     return; // only score opening fills - that's where execution quality matters here

   double fill_price  = HistoryDealGetDouble(trans.deal, DEAL_PRICE);
   double point         = SymbolInfoDouble(_Symbol, SYMBOL_POINT);
   double slippage_pts = (g_pending_request_price > 0.0)
                 ? MathAbs(fill_price - g_pending_request_price) / point
                 : 0.0;

   double latency_ms   = (g_pending_request_ticks > 0)
                 ? (double)(GetTickCount64() - g_pending_request_ticks)
                 : 0.0;

   double spread_pts    = (double)SymbolInfoInteger(_Symbol, SYMBOL_SPREAD);
   double filled_vol     = HistoryDealGetDouble(trans.deal, DEAL_VOLUME); // actual filled size, NOT the requested size
   double volume_dev_pct = (InpLots > 0.0) ? MathAbs(filled_vol - InpLots) / InpLots * 100.0 : 0.0; // InpLots stands in for "requested size" - see note below
   double reject_flag    = 0.0; // this deal filled; rejections/requotes are a separate event stream

   double features[EXEC_FEATURE_COUNT];
   features[0] = slippage_pts;
   features[1] = latency_ms;
   features[2] = spread_pts;
   features[3] = volume_dev_pct;
   features[4] = reject_flag;

   g_logger.Push(features);

   double anomaly_score = 0.5;
   bool   is_anomaly    = false;
   if(g_forest.IsTrained())
     {
      ulong t0 = GetTickCount64();
      anomaly_score = g_forest.AnomalyScore(features);
      is_anomaly    = (anomaly_score >= InpAnomalyThreshold);
      if(InpLogTimings)
        Print("IsoForest: AnomalyScore() took ", (GetTickCount64() - t0), " ms");
     }

   g_logger.LogToCsv(features, anomaly_score, is_anomaly);
   RecordAnomalyOutcome(is_anomaly);

   if(is_anomaly)
     HandleAnomaly(anomaly_score);

   g_trades_since_retrain++;
   MaybeRetrainForest();
  }
//+------------------------------------------------------------------+
//| SaveToFile / LoadFromFile                                        |
//+------------------------------------------------------------------+
bool CIsoForest::SaveToFile(const string filename)
  {
   int handle = FileOpen(filename, FILE_WRITE | FILE_BIN | FILE_COMMON);
   if(handle == INVALID_HANDLE)
     {
      Print("CIsoForest::SaveToFile - failed to open ", filename, " err=", GetLastError());
      return(false);
     }

   FileWriteInteger(handle, m_num_trees);
   FileWriteInteger(handle, m_subsample_size);
   FileWriteInteger(handle, m_feature_count);
   for(int t = 0; t < m_num_trees; t++)
     m_trees[t].Save(handle);

   FileClose(handle);
   return(true);
  }

bool CIsoForest::LoadFromFile(const string filename)
  {
   if(!FileIsExist(filename, FILE_COMMON))
     return(false);

   int handle = FileOpen(filename, FILE_READ | FILE_BIN | FILE_COMMON);
   if(handle == INVALID_HANDLE)
     return(false);

   m_num_trees      = FileReadInteger(handle);
   m_subsample_size = FileReadInteger(handle);
   m_feature_count  = FileReadInteger(handle);

   ArrayResize(m_trees, m_num_trees);
   for(int t = 0; t < m_num_trees; t++)
     m_trees[t].Load(handle);

   FileClose(handle);
   m_trained = true;
   return(true);
  }

The save/load pair mirrors itself exactly - same field order, same types, read and write calls matched line for line - which is what makes the flat binary format safe to round-trip without a version tag beyond the three header integers already used for the contract check.


How to run and reproduce this

1. Copy the three source files into your local MQL5 folder: Experts\IsoForestExecMonitor\IsoForestExecMonitor.mq5, and both .mqh files into Include\IsoForestExecMonitor\. Compile the EA in MetaEditor - it will pull in both includes automatically via the paths shown in the source.

2. Attach it to an XAUUSD M5 chart (or your own symbol/timeframe - nothing in the logic is gold-specific) with default inputs, either on a demo account or in the Strategy Tester in "Every tick" or "Every tick based on real ticks" mode so OnTradeTransaction() fires realistically.

3. Let it trade until InpTrainingWindow (500 by default) fills have accumulated. Until then the Journal will keep showing the "no persisted model found" message and every logged row will carry the placeholder 0.5 score - this is expected, not a fault.

4. Check that execution_log.csv exists under your terminal's common data folder, in an IsoForestExecMonitor subfolder (Common data folder path is visible in MetaTrader under File - Open Data Folder - the Common folder is a sibling of the per-terminal one). Open it and confirm rows are accumulating with the schema shown above.

5. Once the training window fills, watch the Journal for the "retrained natively on N executions" message, and confirm isoforest_model.bin now exists alongside the CSV in the same common folder.

6. Keep trading past the retrain point and watch for an "Execution-quality anomaly detected, score=..." message - on a demo account with stable conditions this may be rare by design, which is itself useful information about your execution environment.

7. Export the CSV, point iso_forest_crossvalidation.py at it (python iso_forest_crossvalidation.py with the file in the working directory, after pip install pandas numpy scikit-learn), and confirm it produces a tuning_sensitivity_report.csv and prints the sensitivity table to the console.

8. Turn on InpLogTimings and re-run step 6 briefly to see actual scoring and retrain elapsed-millisecond numbers from your own hardware in the Journal, rather than taking the complexity argument in this article on faith.


What this implementation does not cover

Being explicit about scope here increases trust in what's actually built, rather than pretending this is more general-purpose than it is.

Multi-symbol or multi-EA request context collisions. g_pending_request_price/g_pending_request_ticks are single global slots. Running this logic across several symbols or several concurrent EA instances sharing state would need a per-request-ID or per-symbol context map, not two globals.

Concurrent/overlapping requests. The host strategy only ever has one position and one pending request in flight at a time. A busier strategy issuing multiple simultaneous orders would silently overwrite request context between them - this needs a request-ticket-keyed structure to be safe for that use case.

Partial fills split across several deals. The volume-deviation feature compares the single confirming deal's volume against the requested lot size. A request partially filled across multiple separate deal events isn't reassembled into one logical fill here.

Asynchronous order workflows. trade.Buy()/trade.Sell() are used synchronously. An EA built around CTrade::SetAsyncMode(true) would need the request-context capture moved to right after the async send confirms acceptance, not before.

Broker-specific rejection/requote semantics. The reject-flag placeholder is described above - real rejection/requote codes vary by broker and aren't parsed into the vector in this version.

Model versioning or backward compatibility. The persisted binary format has no version tag beyond the three header integers used for the feature-count contract check. A future format change would not be readable by this loader and isn't designed to be.

News-calendar suppression. The pitfall about volatility-driven anomaly spikes during news is named but not handled - there's no calendar integration filtering the halt logic during known high-impact windows.

Automatic threshold calibration. InpAnomalyThreshold is a static input, set offline from the Python validation workflow. There is no in-EA logic that recalibrates it dynamically from the rolling score distribution.


Testing in the Strategy Tester

The host strategy is intentionally simple - a plain fast/slow EMA cross on XAUUSD M5 - because the point of this article is the monitor, not the alpha. Comparing the equity curve with the circuit breaker enabled versus disabled isolates the monitor's contribution rather than mixing it with strategy quality.

Setting
Value
Symbol / Timeframe
XAUUSD / M5
Fast / Slow EMA
12 / 48
Training window / retrain cadence
500 fills / every 100 fills
Trees / subsample size
100 / 256
Anomaly threshold
0.68
Halt trigger
35% anomaly rate over trailing 30 trades

Real results, both runs. Two Strategy Tester runs, identical in every respect - XAUUSD M5, "Every tick based on real ticks," same date range covering 28,098 bars and 36,520,722 ticks, 100% history quality - except one input:

Metric
Breaker ON (threshold=0.68)
Breaker OFF (threshold=2.0)
Total Trades
695
695
Total Net Profit
-292.40
-292.40
Profit Factor
0.98
0.98
Expected Payoff
-0.42
-0.42
Balance Drawdown Maximal
1960.50 (19.60%)
1960.50 (19.60%)
Equity Drawdown Maximal
1975.90 (19.74%)
1975.90 (19.74%)
Anomalies flagged (Journal)
0
0
Cooldowns / halts triggered
0/0
0/0

The two runs are identical down to the cent, and that's the honest result, not a bug. The execution log from the same window explains why - the figure below shows it directly.

This is the "constant features stall the tree" pitfall at the system level: the Tester's fill model doesn't produce realistic slippage, latency, or partial-fill variance, so the breaker has little to detect in Tester-only runs. That's a real finding about the limits of this validation method, not a reason to doubt the detector logic, which the synthetic injection tests above already exercised successfully. Meaningfully testing the breaker's P&L effect needs live/demo data with genuine variance, or the Python script's injection scheme run through the scoring path directly.

Fig. 5. Real data from the two Tester runs above. Left: anomaly score distribution across all 1,585 scored fills (the 500 cold-start fills before the first retrain are excluded, since they only carry the 0.5 placeholder) - every real score sits well under the 0.68 threshold. Right: the persisted forest's actual shape, pulled from the saved model file - 58 of 100 trees are a single node, the direct consequence of the constant-feature pitfall described above.

Judge a real deployment by max drawdown during bad-liquidity windows, not raw return - that's what this layer protects, and identical-to-the-cent Tester results don't mean the breaker is inert. It means this window lacked the execution stress it exists to catch; a demo or live account with genuine broker-side variance is a fundamentally different test than clean historical ticks.

What the validation and testing actually established:

1. The native MQL5 implementation runs correctly end to end - contract checks, training, persistence, and scoring all behave as designed, confirmed by both the synthetic Python cross-check and the real Strategy Tester runs.

2. Synthetic injection testing supports the ranking quality of the scoring math itself - real anomalies separate from normal fills in the score distribution, and native scores track the sklearn reference closely enough to trust the implementation.

3. The Strategy Tester's default execution model is a poor proxy for real execution-quality anomalies - it doesn't generate the slippage, latency, or fill-volume variance that live broker infrastructure does under stress, so a Tester-only P&L comparison is close to a null test by construction.

4. The circuit breaker's actual trading impact - not just its scoring correctness - still needs to be measured against a demo or live execution stream with genuine variance, or against a deliberately perturbed synthetic feed, before its P&L effect can be claimed either way.


Conclusion

Execution quality is something every trader knows matters and almost nobody instruments. Most systems get tuned against price and backtest results; the fills that carry those trades rarely get watched at all, so a broker's liquidity thinning out or a VPS connection degrading tends to show up only after the equity curve has already absorbed the damage. Treating "how well did that fill go" as its own signal, worth logging and reacting to in real time, closes that blind spot.

What this article actually demonstrates: a randomized tree ensemble, path-length scoring, and an event-driven hook into the trade transaction stream can be built entirely native to MQL5, with no ONNX, ALGLIB, or external process - the same architecture proven out end to end, from feature contract to persistence to live scoring, in both the synthetic validation and the real Strategy Tester runs above. What it does not yet demonstrate is a measured P&L benefit, for the honest reason that the Tester's execution model didn't produce the conditions the breaker exists to catch.

The practical next step for anyone adapting this is a demo or live deployment with real broker variance, watching the same two things this article tracked: whether anomaly scores separate cleanly from the normal-fill baseline, and whether cooldowns/halts actually correlate with periods your own execution logs would flag independently. Treat the threshold and retrain cadence as things you calibrate against your own log, not constants copied from an article - and treat a quiet Journal as a legitimate outcome, not a failure to find something.

File
Type
Description
IsoForestExecMonitor.mq5
Expert Advisor
Host EMA-cross strategy with the native isolation-forest execution-quality monitor and circuit breaker wired in.
IsoForest.mqh
Include
CIsoTree and CIsoForest classes - native randomized tree construction, path-length scoring, and FILE_COMMON persistence.
ExecFeatureLogger.mqh
Include
CExecFeatureLogger class - rolling execution feature buffer and CSV export for offline review.
iso_forest_crossvalidation.py
Python script
Offline-only companion: synthetic anomaly injection, scikit-learn cross-check of the native scoring math, and the tuning-sensitivity sweep.
Attached files |
MQL5.zip (10.2 KB)
The ZeroMQ Message Transfer Protocol in MQL5: Implementing the REQ/REP pattern The ZeroMQ Message Transfer Protocol in MQL5: Implementing the REQ/REP pattern
This article presents a native MQL5 implementation of the ZeroMQ Message Transfer Protocol (ZMTP) built on raw MQL5 sockets. It explains the REQ/REP pattern via the CZmqReqSocket class, including framing, handshake, and strict send/receive alternation. A practical pipeline shows an MQL5 script streaming returns to a Python/R server running MS‑GARCH and receiving regime probabilities, enabling integration without DLLs.
The MQL5 Standard Library Explorer (Part 16): Building a Regime-Adaptive Expert Advisor The MQL5 Standard Library Explorer (Part 16): Building a Regime-Adaptive Expert Advisor
We convert the Part 15 decision‑forest classifier into a regime‑adaptive Expert Advisor that decouples statistical inference from trading authority. The EA trains on completed bars, scores each new completed bar, and confirms stable bullish, neutral, or bearish regimes before acting. It then applies spread, ownership, risk, and execution checks to authorize opening, holding, closing, or blocking a position.
Neural Networks in Trading: The Temporal Query Model (Conclusion) Neural Networks in Trading: The Temporal Query Model (Conclusion)
We are pleased to present the final stage of the TQNet framework’s development and testing, where theory meets real-world trading practice. We will move from historical training to a stress test using recent market data, evaluating the model's robustness and accuracy. The final results are not just dry statistics, but also a clear demonstration of the practical value of the proposed approach.
MetaTrader 5 as a Kafka Producer: Event-Bus Architecture for Multi-Terminal Signal Fan-Out MetaTrader 5 as a Kafka Producer: Event-Bus Architecture for Multi-Terminal Signal Fan-Out
The article details a native MQL5 Kafka producer that speaks the wire protocol over raw TCP. It implements RecordBatch v2 encoding, varints, and CRC32C, and adds batching, acks, and retry logic, all without a sidecar or DLL. Use it to publish JSON-structured trading signals from a single terminal to Kafka, where dashboards and other services subscribe independently.