Русский Español Português
preview
Exploring Regression Models for Causal Inference and Trading

Exploring Regression Models for Causal Inference and Trading

MetaTrader 5Integration |
1 157 15
[Deleted]

Introduction

Over the course of several articles, we have explored various methods of classifying time series, but we have not touched upon regression models. Regression models, unlike binary classification, allow us to predict not the probability of an observation belonging to a particular class, but continuous values, which expands the possibilities of their application for creating automated trading systems.

Binary classification is a fundamental machine learning task in which the goal is to classify input data into one of two different categories or classes. In the context of a Forex trading bot, this usually means predicting a "buy" (represented as 0) or "sell" (represented as 1) signal. This approach reduces complex market dynamics to a simple directional decision.

The most significant inherent limitation of binary classification for quantitative trading is its inability to quantify the magnitude or intensity of a predicted price movement. A binary classifier only indicates whether the price is expected to move up or down, without providing any information about how much it is expected to change. This lack of granularity fundamentally limits decision-making in trading.

The accuracy of the classifier's predictions alone does not take into account the magnitude of change, and is therefore not very useful for trading. This aspect is key because it highlights that high directional accuracy (e.g. predicting the correct direction 70% of the time) does not automatically lead to profitable trading. 

There is an important observation that high direction accuracy does not guarantee profitability. For example, you can be right 30% of the time and be profitable, or you can be right 70% of the time and be unprofitable. This demonstrates that the net result of a trading strategy is determined by the amount of profit on winning trades compared to the amount of loss on losing trades, and not simply the percentage of wins.

By treating all correct directional forecasts equally regardless of the actual price movement, the binary classification model is unable to distinguish between a small, insignificant price movement and a large, highly profitable one. This can lead to a scenario where many small winning trades are canceled out by a few large losing trades, or vice versa, resulting in an overall negative PnL despite seemingly high accuracy.

The lack of quantification of price movement means that the trading bot cannot prioritize trades with higher expected profits or avoid trades where the potential loss significantly outweighs the potential profit, even if the direction is predicted correctly. Without information about the magnitude, the bot operates with a blind spot regarding the actual financial impact of its decisions, resulting in suboptimal or even negative cumulative returns despite a high percentage of direction-based wins.


Labeling function modification

Consider the following scenario: there is a financial time series that needs to be predicted based on a set of features. In the case of binary classification, the direction of a future trade (buy or sell) can be determined, and these labels are always fixed. We cannot label them differently to obtain a more accurate estimate of the magnitude of future price deviations. The trades are equivalent regardless of how much the price actually changed.

Now imagine that we can predict not only the direction of a trade, but also the magnitude of the future change. This will allow us to fine-tune our trading system by creating additional filters that will help identify only the predicted price fluctuations that are significant for trading and exclude insignificant ones.

To train a regression model, it is necessary to prepare features and targets for its training. The features may be shared by the classifier and the regressor, while the target variables will differ.

Let's write a simple function that implements example labeling for a regression model:

@njit
def calculate_labels_r(close_data, min_val, max_val):
    labels = []
    for i in range(len(close_data) - max_val):
        rand = random.randint(min_val, max_val)
        labels.append(close_data[i + rand] - close_data[i])
    return labels

def get_labels_r(dataset, min = 1, max = 15) -> pd.DataFrame:
    # Extract closing prices from the dataset
    close_data = dataset['close'].values
    labels = calculate_labels_r(close_data, min, max)
    # Trim the dataset to match the length of calculated labels
    dataset = dataset.iloc[:len(labels)].copy() 
    # Add the calculated labels as a new column
    dataset['labels'] = labels
    # Remove rows with NaN values (potentially introduced in 'calculate_labels')
    dataset = dataset.dropna()
    return dataset

The main difference from the binary classification labeling is that we now determine price changes (subtract the current price from the future price) instead of simply determining the direction (buy or sell). The code is accelerated by Numba, so target labeling is very fast.

The above function only takes into account the difference between a randomly selected future price in the range {min_val; max_val} and the current one. This may not be entirely correct, since intermediate deviations, which may be significant, are not taken into account. I propose another modification of the deviation calculation function, which is presented below.

@njit
def calculate_labels_mean_r(close_data, min_val, max_val):
    labels = []
    for i in range(len(close_data) - max_val):
        # Calculate the average price value in the window from min_val to max_val
        future_prices = close_data[i + min_val : i + max_val + 1]
        mean_future_price = np.mean(future_prices)
        # Calculate the difference between the average future value and the current price
        labels.append(mean_future_price - close_data[i])
    return labels

def get_labels_r(dataset, min = 1, max = 15) -> pd.DataFrame:
    # Extract closing prices from the dataset
    close_data = dataset['close'].values
    # Calculate buy/hold labels based on future price movements
    labels = calculate_labels_mean_r(close_data, min, max)
    # Trim the dataset to match the length of calculated labels
    dataset = dataset.iloc[:len(labels)].copy() 
    # Add the calculated labels as a new column
    dataset['labels'] = labels
    # Remove rows with NaN values (potentially introduced in 'calculate_labels')
    dataset = dataset.dropna()
    return dataset

Now the function takes into account all deviations in the given interval, calculating the average value. After this, the difference between the average value of future prices and the current price is calculated. Accordingly, the get_labels_r() function now calls the calculate_labels_mean_r() labeling function, and not calculate_labels_r() as before. We can experiment by calling different labeling functions.


Adding a causal inference system

For more accurate predictions, we use an algorithm similar to the one described in the article about the causal inference. The main difference will be the use of a regressor rather than a classifier.

def meta_learners(data, models_number: int, iterations: int, depth: int):
    data = data.copy()
    data = data[(data.index < hyper_params['forward']) & (data.index > hyper_params['backward'])].copy()

    X = data[data.columns[1:-1]]
    y = data['labels']
    data['meta_labels'] = 0

    for i in range(models_number):
        X_train, X_val, y_train, y_val = train_test_split(
            X, y, train_size = 0.5, test_size = 0.5, shuffle = True)
        
        # fit debias model with train and validation subsets
        meta_m = CatBoostRegressor(iterations = iterations,
                                depth = depth,
                                verbose = False,
                                use_best_model = True)
        
        meta_m.fit(X_train, y_train, eval_set = (X_val, y_val), plot = False)
        
        coreset = X.copy()
        coreset['labels'] = y
        coreset['labels_pred'] = meta_m.predict(X)
        data['meta_labels'] += abs(coreset['labels'] - coreset['labels_pred'])

    data['meta_labels'] = data['meta_labels'] / models_number
    return data

The function trains multiple regressors on random subsets of data from the original dataset and then compares the actual targets with the predicted ones. Thus, the meta model will not predict 0 or 1 (trade or not trade), but the average values of deviations of forecasts from actual ones. This way we can filter out forecasts that deviate significantly from expected values.


Training and testing trained models

For testing regression models, the tester was modified. Now it features the 'r' suffix. It is time to train several models. In this article, I will train 10 models and select the one I like best.

hyper_params = {
    'symbol': 'EURUSD_H1',
    'export_path': '/Users/dmitrievsky/drive_c/Program Files/MetaTrader 5/MQL5/Include/Trend following/',
    'model_number': 0,
    'markup': 0.00010,
    'stop_loss':  0.00500,
    'take_profit': 0.00200,
    'periods': [i for i in range(5, 100, 30)],
    'backward': datetime(2010, 1, 1),
    'forward': datetime(2024, 1, 1),
}

models = []
for i in range(10):
    print('Learn ' + str(i) + ' model')
    data = get_labels_r(get_features(get_prices()), min=1, max=15)
    dataset = meta_learners(data=data, models_number=5, iterations=15, depth=3)
    models.append(fit_final_models(dataset, tol=3e-2))

Here we should pay attention to the tol parameter, which is passed to the final model training function. Since we want to make the main model as robust as possible, there is no point in training it on all examples. We will train it only on those examples whose predictions deviate from the actual ones by less than the tol value.

Since deviations from predictions are actually counted in points, then tol=3e-2 will mean a maximum difference of 0.03 or 300 4-digit points. It may seem like a large difference as a filter, but it is worth considering that this is a difference in absolute values, as predictions can be either positive or negative. You can experiment with this parameter. Below is the function itself.

def fit_final_models(dataset, tol=1e-2) -> list:
    # features for model\meta models. We learn main model only on filtered labels
    X = dataset[dataset['meta_labels'] < tol]
    X, X_meta = X[X.columns[1:-2]], dataset[dataset.columns[1:-2]]
    # labels for model\meta models
    y = dataset[dataset['meta_labels'] < tol]
    y, y_meta = y[y.columns[-2]], dataset[dataset.columns[-1]]
    
    # fit main model with train and validation subsets
    model = RandomForestRegressor(n_estimators=50, max_depth=10)
    model.fit(X, y)
    # fit meta model with train and validation subsets
    meta_model = RandomForestRegressor(n_estimators=50, max_depth=10)
    meta_model.fit(X_meta, y_meta)

    data = get_features(get_prices())
    R2 = test_model_r(data, 
                    [model, meta_model], 
                    hyper_params['stop_loss'], 
                    hyper_params['take_profit'],
                    hyper_params['forward'],
                    hyper_params['backward'],
                    hyper_params['markup'],
                    plt=False)
    
    if math.isnan(R2):
        R2 = -1.0
        print('R2 is fixed to -1.0')
    print('R2: ' + str(R2))
    result = [R2, model, meta_model]
    return result

Now let's sort the models and call the custom tester function:

models.sort(key=lambda x: x[0])
data = get_features(get_prices())
test_model_r(data, 
        models[-1][1:], 
        hyper_params['stop_loss'], 
        hyper_params['take_profit'],
        hyper_params['forward'],
        hyper_params['backward'],
        hyper_params['markup'],
        plt=True)

The model is overfitted and performs poorly on new data:

Fig. 1. Testing the model with basic labeling

We will carry out exactly the same manipulations, but we will use the calculate_labels_mean_r() trade-labeling function, which calculates average future prices.

Fig. 2. Testing the model with averaged labeling

The second trade-labeling function, on average, shows more stable results on new data. Apparently this is due to the fact that the average value of future prices is taken into account.

The custom tester does not have the ability to define thresholds for the main regression model, so it simply divides the model predictions into positive and negative, meaning the signals are still quite rough. But we will fix this directly in the MetaTrader 5 terminal.


Exporting models to the MetaTrader 5 terminal

Now we need to export the models to the terminal in ONNX format and configure the trading system. The export function looks familiar:

export_model_to_ONNX(model = models[-1],
                     symbol = hyper_params['symbol'],
                     periods = hyper_params['periods'],
                     periods_meta = hyper_params['periods'],
                     model_number = hyper_params['model_number'],
                     export_path = hyper_params['export_path'])

It should be noted that I was unable to use CatBoost regression models in ONNX format with the terminal, so I used Random Forest instead.

The dimension of the input tensor is adjusted automatically depending on the hyperparameters (number of features) that are specified before training begins. Next, the models are converted to ONNX format using the convert_sklearn() function and saved to disk in the directory you specified in the hyperparameters.

def export_model_to_ONNX(**kwargs):
    model = kwargs.get('model')
    symbol = kwargs.get('symbol')
    periods = kwargs.get('periods')
    periods_meta = kwargs.get('periods_meta')
    model_number = kwargs.get('model_number')
    export_path = kwargs.get('export_path')

    initial_type = [('float_input', FloatTensorType([None, len(hyper_params['periods'])]))]
    onnx_model = convert_sklearn(model[1], initial_types=initial_type)
    # save main model to ONNX
    with open(export_path +'catmodel ' + symbol + ' ' + str(model_number) +'.onnx', "wb") as f:
        f.write(onnx_model.SerializeToString())
    onnx_model_meta = convert_sklearn(model[2], initial_types=initial_type)
    # save meta model to ONNX
    with open(export_path +'catmodel_m ' + symbol + ' ' + str(model_number) +'.onnx', "wb") as f:
        f.write(onnx_model_meta.SerializeToString())
    
    code = '#include <Math\Stat\Math.mqh>'
    code += '\n'
    code += '#resource "catmodel '+ symbol + ' '+str(model_number)+'.onnx" as uchar ExtModel_' + symbol + '_' + str(model_number) + '[]'
    code += '\n'
    code += '#resource "catmodel_m '+ symbol + ' '+str(model_number)+'.onnx" as uchar ExtModel2_' + symbol + '_' + str(model_number) + '[]'
    code += '\n\n'
    code += 'int Periods' + symbol + '_' + str(model_number) + '[' + str(len(periods)) + \
        '] = {' + ','.join(map(str, periods)) + '};'
    code += '\n'
    code += 'int Periods_m' + symbol + '_' + str(model_number) + '[' + str(len(periods_meta)) + \
        '] = {' + ','.join(map(str, periods_meta)) + '};'
    code += '\n\n'

    # get features
    code += 'void fill_arays' + symbol + '_' + str(model_number) + '( double &features[]) {\n'
    code += '   double pr[], ret[];\n'
    code += '   ArrayResize(ret, 1);\n'
    code += '   for(int i=ArraySize(Periods'+ symbol + '_' + str(model_number) + ')-1; i>=0; i--) {\n'
    code += '       CopyClose(NULL,PERIOD_H1,1,Periods' + symbol + '_' + str(model_number) + '[i],pr);\n'
    code += '       ret[0] = MathStandardDeviation(pr);\n'
    code += '       ArrayInsert(features, ret, ArraySize(features), 0, WHOLE_ARRAY); }\n'
    code += '   ArraySetAsSeries(features, true);\n'
    code += '}\n\n'

    # get features
    code += 'void fill_arays_m' + symbol + '_' + str(model_number) + '( double &features[]) {\n'
    code += '   double pr[], ret[];\n'
    code += '   ArrayResize(ret, 1);\n'
    code += '   for(int i=ArraySize(Periods_m' + symbol + '_' + str(model_number) + ')-1; i>=0; i--) {\n'
    code += '       CopyClose(NULL,PERIOD_H1,1,Periods_m' + symbol + '_' + str(model_number) + '[i],pr);\n'
    code += '       ret[0] = MathStandardDeviation(pr);\n'
    code += '       ArrayInsert(features, ret, ArraySize(features), 0, WHOLE_ARRAY); }\n'
    code += '   ArraySetAsSeries(features, true);\n'
    code += '}\n\n'

    file = open(export_path + str(symbol) + ' ONNX include' + ' ' + str(model_number) + '.mqh', "w")
    file.write(code)

    file.close()
    print('The file ' + 'ONNX include' + '.mqh ' + 'has been written to disk')


Setting thresholds in the MetaTrader 5 terminal

Now that we have two regression models instead of two classifiers, we have the ability to set specific numerical thresholds.

Fig. 3. Setting up signal activation thresholds in the terminal

  • The buy_threshhold and sell_threshhold are responsible for filtering the signals of the main regression model. If the signal is below this threshold, no trades are opened. For example, if the predicted price change is less than 10 pips, then opening such a trade does not make much sense, since it will not cover the spread and commission.
  • The meta_threshhold filters the signals of the main model based on the causal inference described earlier. It tests how much the forecast is likely to differ from the future actual change. If the difference is too big, then the trades will not be opened either.

Now let's test our model in the MetaTrader 5 tester with the given thresholds:

Fig. 4. Testing a model with given thresholds

Let me remind you that the forward period starts at the beginning of 2024 and the model now handles this forward period quite steadily. This highlights the importance of properly defining and setting thresholds. You can optimize the threshold values yourself.

The variety of models can be large, depending on the type of features (in this article, both models are trained on standard deviations) and on the model parameters themselves. For example, another model was trained with different parameters, which performed well on new data even without adjusting the thresholds.

Fig. 5. Training and testing a different model with different training parameters

After adjusting the thresholds, the model showed steady growth from the beginning of 2024.

Fig. 6. Testing the model in the terminal after setting the thresholds

Testing models based on a second trade-labeling function and filtering by thresholds yields even more interesting and accurate results:

Fig. 7. Testing the model based on the average trade-labeling function and tol = 1e-2

If we change the tol parameter when training from 1e-2 to 1e-3, the results will be even better:

Fig. 8. Testing the model based on the average trade-labeling function and tol = 1e-3


Additional information

To export models, we need to install and import the skl2onnx package:

from skl2onnx import convert_sklearn
from skl2onnx.common.data_types import FloatTensorType

The ONNX model launch code in the trading bot code has also been modified to correctly handle new regression models:

vectorf y_main(1), y_meta(1);
   
OnnxRun(ExtHandle, ONNX_DEBUG_LOGS, f, y_main);
OnnxRun(ExtHandle2, ONNX_DEBUG_LOGS, f_m, y_meta);

float sig = y_main[0];
float meta_sig = y_meta[0];

Added new input variables for adjusting and optimizing thresholds:

input double buy_threshold = 0.00001;
input double sell_threshold = -0.00001;
input double meta_threshold = 0.001;

 ONNX model names are now accessed via #define directives, making it easier to include models with different names:

#define model ExtModel_EURUSD_H1_0
#define model_m ExtModel2_EURUSD_H1_0
#define periods PeriodsEURUSD_H1_0
#define periods_m Periods_mEURUSD_H1_0
#define fill_arrays fill_araysEURUSD_H1_0
#define fill_arrays_m fill_arays_mEURUSD_H1_0

Trading signals are generated when conditions are triggered depending on the thresholds:

if((Ask-Bid < max_spread*_Point) && MathAbs(meta_sig) < meta_threshold &&
      AllowTrade(OrderMagic))
      if(countOrders(OrderMagic) < max_orders &&
         CheckMoneyForTrade(_Symbol, LotsOptimized(), ORDER_TYPE_BUY))
        {
         double l = LotsOptimized();
         if(sig > buy_threshold && Allow_Buy)
           {
            int res = -1;
            do
              {
               double stop = Bid - stoploss * _Point;
               double take = Ask + takeprofit * _Point;
               res = mytrade.PositionOpen(_Symbol, ORDER_TYPE_BUY, l, Ask, stop, take, bot_comment);
               Sleep(50);
              }
            while(res == -1);
           }
         else
           {
            if(sig < sell_threshold && Allow_Sell)
              {
               int res = -1;
               do
                 {
                  double stop = Ask + stoploss * _Point;
                  double take = Bid - takeprofit * _Point;
                  res = mytrade.PositionOpen(_Symbol, ORDER_TYPE_SELL, l, Bid, stop, take, bot_comment);
                  Sleep(50);
                 }
               while(res == -1);
              }
           }
        }

The modified strategy tester and model export function have been added to the corresponding modules and attached to the article.


Conclusion

In this article, I described a possible, but not the only, way to build trading systems based on regression models. This approach allows for more fine-tuning of machine learning-based bots. It also allows turning obviously unprofitable models into profitable ones by setting thresholds. You can use any features and/or trade-labeling functions, and also test this algorithm on other trading instruments and other timeframes.

The Python files.zip archive contains the following files for development in the Python environment:

Filename Description
causal regression.py 
The main script for training models
labeling_lib.py
Updated trade-labeling module
tester_lib.py
Updated custom strategy tester based on machine learning
export_lib.py Module for exporting models to the terminal
EURUSD_H1.csv
Quote data exported from MetaTrader 5

The MQL5 files.zip archive contains files for the MetaTrader 5 terminal:

Filename Description
regression trader.ex5
The compiled bot from the article
regression trader.mq5
Bot source code from the article
Include//Trend following folder
The ONNX models and the header file for connecting to the bot

Translated from Russian by MetaQuotes Ltd.
Original article: https://www.mql5.com/ru/articles/18603

Attached files |
MQL5_files.zip (356.92 KB)
Python_files.zip (1092.44 KB)
Last comments | Go to discussion (15)
[Deleted] | 8 Aug 2025 at 08:50
Guanlin Qi #:

I use causal_regression_orig.py to produce ea header file, then compile ea.

The result is test_result pic in below.

There are so less trades than the one you posted.

So what's the difference between these.

Are there many trades in python tester? If yes, you need to reconfigure the thresholds for opening trades in mql5 programme, maybe they are too big.
Guanlin Qi
Guanlin Qi | 14 Aug 2025 at 07:00
Maxim Dmitrievsky #:
Are there a lot of trades in python tester? If yes, you need to reconfigure the thresholds for opening trades in mql5 programme, maybe they are too big.


This is the python tester result, I use the same code as you attached in this post. I checked in mt5 tester config panel, the buy_threshold and sell_threshold are both 0.0001 and -0.0001, same as the setting in this post.

I checked the code, and didnot know what is the difference between this.

[Deleted] | 18 Aug 2025 at 21:05
Guanlin Qi #:


This is the python tester result, I use the same code as you attached in this post. I checked in mt5 tester config panel, the buy_threshold and sell_threshold are both 0.0001 and -0.0001, same as the setting in this post.

I checked the code, and didnot know what is the difference between this.

In the article meta-threshold is 0.001, because of this there may be few trades too. This threshold should be increased, try to make it 0.01 or 0.1 for tests. It should work. All thresholds can be optimised later for more fine-tuning.
GOT.IT
GOT.IT | 12 Sep 2025 at 14:37

Greetings Maxim, thank you very much for sharing with us.

Question. After calculating ONNX for a new ticker and placing it in the Trend Following folder, how can I make sure that the EA starts using the trained data for the new ticker? For other tickers, even if I delete ONNX_EURUSD and place another one, the EA behaves identically. Do I need to change something in MQL5 Expert Advisor when changing the ticker?

[Deleted] | 21 Sep 2025 at 10:30
GOT.IT #:

Greetings Maxim, thank you so much for sharing with us.

Question. After calculating ONNX for a new ticker and placing it in the Trend Following folder, how can I make sure that the EA starts using the trained data for the new ticker? For other tickers, even if I delete ONNX_EURUSD and place another one, the EA behaves identically. Do I need to change something in MQL5 Expert Advisor when changing the ticker?

Hi, you need to connect the .mqh file with the new ticker in the EA code.

#include <Trend following/EURUSD_H1 ONNX include 0.mqh>

That is, change the selected one to another file with a different name, which was generated by the python script.

CSV Data Analysis (Part 1): CSV Export Engine for MQL5 Multi-Core Optimizations CSV Data Analysis (Part 1): CSV Export Engine for MQL5 Multi-Core Optimizations
Multi-core optimization in MetaTrader 5 can silently drop results when parallel agents contend for the same CSV file. A reusable MQL5 export engine applies an iteration-based spin-lock to acquire the file handle reliably and append rows without loss. It persists custom metrics such as the Sortino Ratio, average trade duration, and signal-quality measures (lag and whipsaws) into a consolidated CSV for downstream analysis.
Recurrence Network Analysis (RNA) in MQL5: From Recurrence Matrices to Complex Networks Recurrence Network Analysis (RNA) in MQL5: From Recurrence Matrices to Complex Networks
The article extends the MQL5 recurrence library to Recurrence Network Analysis (RNA) by treating recurrence matrices as adjacency matrices of undirected graphs. It implements core network metrics—clustering, transitivity, average path length, betweenness, assortativity, and density—and applies them in rolling windows for single-series RNA and Joint RNA (JRNA). A modular metrics engine and two indicators visualize the evolving network structure on MetaTrader 5 charts for practical time-series analysis.
Position Management: A Reusable Trade Journal with Live Maximum Adverse Excursion, Maximum Favorable Excursion, and R-Multiple Tracking in MQL5 Position Management: A Reusable Trade Journal with Live Maximum Adverse Excursion, Maximum Favorable Excursion, and R-Multiple Tracking in MQL5
This article presents CTradeJournal, a self-contained MQL5 class for live tracking of open positions at tick frequency. It maintains MAE, MFE, and initial risk in money, calculates the R-multiple when a position closes, and writes a complete CSV record. The text explains the design choices, provides the implementation, and shows simple EA integration so you can analyze entries, stop placement, and outcome distribution.
How to Detect and Normalize Chart Objects in MQL5 (Part 2): Collecting and Structuring Data from Complex Analytical Objects How to Detect and Normalize Chart Objects in MQL5 (Part 2): Collecting and Structuring Data from Complex Analytical Objects
Manually drawn analytical object tools like Fibonacci tools, and Andrews Pitchforks are invisible to automated trading logic. This article extends a base detector to extract anchor points, level arrays, and geometric offsets from complex objects. You will implement a reusable collector that normalizes the raw chart data into structured memory arrays, ready for strategy decisions.