A "market-structure classifier" sorts each bar into one of three states: uptrend, downtrend, or range. It doesn't predict price; it labels the current regime. Used as a filter on top of an existing rule-based EA, it can suppress trades in conditions where the EA historically loses. This article walks through the design, the labels, the features, the export, and the MQL5 integration.

Why a filter, not a forecaster

Price forecasting is hard (efficient markets, low signal-to-noise). Regime classification is easier because the labels are coarser and more stable. Pairing a known-edge rule-based EA with an ML-driven "don't trade when the model says range" filter is usually a higher-payoff use of ML than trying to predict price directly.

How to label market structure for training

For each bar in your training history, generate a label using a rule you'd never want the EA to encode (because the rule looks into the future):

label.py
import numpy as np def label_structure(closes, lookahead=20, range_threshold=0.005): # For each bar, look 20 bars forward and label by realized direction. n = len(closes) labels = np.zeros(n, dtype=np.int64) for i in range(n - lookahead): future_return = (closes[i + lookahead] - closes[i]) / closes[i] if future_return > range_threshold: labels[i] = 1 # uptrend ahead elif future_return < -range_threshold: labels[i] = 2 # downtrend ahead else: labels[i] = 0 # range ahead return labels

The lookahead and threshold are knobs. Tune by checking the class balance — with sensible settings you want roughly 1/3 of bars in each class.

Feature engineering

The features that matter for regime classification (not price forecasting):

Roughly 15–25 engineered features is enough. More than that and the model overfits without enough data.

Training a 3-class classifier

LightGBM is the natural fit — tree-based, robust, fast, and exports cleanly to ONNX:

train.py
import lightgbm as lgb from onnxmltools import convert_lightgbm from onnxmltools.convert.common.data_types import FloatTensorType params = { "objective": "multiclass", "num_class": 3, "learning_rate": 0.05, "num_leaves": 31, "max_depth": 6, } train_data = lgb.Dataset(X_train, label=y_train) booster = lgb.train(params, train_data, num_boost_round=200) initial_types = [("input", FloatTensorType([None, X_train.shape[1]]))] onx = convert_lightgbm(booster, initial_types=initial_types, target_opset=17) with open("regime.onnx", "wb") as f: f.write(onx.SerializeToString())

See LightGBM to ONNX for the full export workflow.

Using it as a filter in MQL5

EA filter logic
#resource "models\regime.onnx" as uchar RegimeModel[] long hRegime; int OnInit() { hRegime = OnnxCreateFromBuffer(RegimeModel, ONNX_USE_CPU_ONLY); const long in_shape[] = {1, 20}; // 20 engineered features const long out_shape[] = {1, 3}; OnnxSetInputShape(hRegime, 0, in_shape); OnnxSetOutputShape(hRegime, 1, out_shape); return(INIT_SUCCEEDED); } int GetRegime() // returns 0=range, 1=up, 2=down { matrixf input(1, 20); vectorf probs(3); // ... fill 20 features the same way training did ... OnnxRun(hRegime, ONNX_NO_CONVERSION, input, probs); // argmax int best = 0; for(int i = 1; i < 3; i++) if(probs[i] > probs[best]) best = i; return best; } void OnTick() { // New bar only static datetime last_bar; datetime now = iTime(NULL, PERIOD_CURRENT, 0); if(now == last_bar) return; last_bar = now; int regime = GetRegime(); if(regime == 0) return; // skip range // ... run base EA logic only when regime is trending ... if(regime == 1 && ShouldBuy()) PlaceBuy(); if(regime == 2 && ShouldSell()) PlaceSell(); }

Run the same EA with and without the filter on the same period in the Strategy Tester. The filter should reduce trade count and improve win-rate / drawdown — that's the whole point. If it doesn't, either the labels aren't predictive or the features aren't matched between training and runtime (see normalization).