Русский Português
preview
N-BEATS Network-Based Forex EA

N-BEATS Network-Based Forex EA

MetaTrader 5Trading systems |
489 2
Yevgeniy Koshtenko
Yevgeniy Koshtenko

Modern Forex trading robots still use indicators from twenty years ago. A moving averages crossover occurs, the RSI is above 70, the MACD has turned down – and the robot makes a decision to buy or sell millions of dollars. It sounds absurd, but this is how most Expert Advisors (EAs) work.

Financial markets have critical features that classical approaches ignore. The signal to noise ratio is approximately 1:10 - nine-tenths of all price movements are random fluctuations. Long-term dependencies play a key role: decisions by the European Central Bank a month ago can still affect the EUR exchange rate today, but classic indicators do not take this into account.

The non-stationarity of markets creates additional complications. Patterns that worked a month ago may become irrelevant due to changes in the macroeconomic situation or the behavior of major players. Any trading system must be able to adapt to these changes.

That is why we turned to the N-BEATS architecture — a revolutionary approach to time series forecasting specifically designed to handle complex time dependencies.



N-BEATS: Architectural breakthrough in time series analysis

Researchers from Element AI proposed a fundamentally new approach to forecasting back in 2019. Instead of modifying existing RNN or CNN architectures, they asked a fundamental question: which architecture is ideal for forecasting tasks?

N-BEATS is based on the principle of time series decomposition. Imagine a musical composition: instead of trying to predict the next note based on the overall sound, we analyze the melody, rhythm, and harmony separately, and then synthesize a prediction. Similarly, N-BEATS decomposes the time series into trend, seasonal fluctuations, and residual patterns.

The architecture uses residual learning, where each block not only makes a forward prediction but also explains the history. If the explanation is inaccurate, the error is passed on to the next block. It is reminiscent of a detective investigation: each investigator explains part of the evidence and passes on unresolved questions to a colleague.

The critical advantage of N-BEATS is interpretability. Unlike the black box of conventional neural networks, the architecture can reveal what role the trend plays in the forecast, and what role seasonal fluctuations play. For a trader, this means understanding not only what will happen, but also why.

The results of testing on standard benchmarks shocked the scientific community. N-BEATS outperformed all existing methods, including LSTM networks and Transformer architectures. Most impressively, it achieved this using only historical data from the time series itself, without external features.

Unlike RNNs, N-BEATS can process the input sequence in parallel rather than strictly sequentially. For algorithmic trading, this means the ability to make real-time predictions in fast-moving markets.

However, adapting the academic architecture to the chaotic world of currency markets requires significant modifications. Quantile forecasts (instead of point estimates), multivariate inputs and special handling of market noise are required.



Basic structure of a Matrix with gradients

MetaTrader 5 does not provide ready-made tools for machine learning, so everything has to be built from scratch. Our system is based on a Matrix structure, which stores not only data but also gradients for each element:

struct Matrix {
    double data[];
    double gradients[];  // Key complement for backprop
    int rows, cols;
    
    void Init(int r, int c) {
        rows = r; cols = c;
        ArrayResize(data, r * c);
        ArrayResize(gradients, r * c);
        ArrayInitialize(data, 0.0);
        ArrayInitialize(gradients, 0.0);
    }
    
    double Get(int r, int c) {
        if(r < 0 || r >= rows || c < 0 || c >= cols) return 0.0;
        return data[r * cols + c];
    }
    
    void AddGrad(int r, int c, double grad) {
        if(r < 0 || r >= rows || c < 0 || c >= cols) return;
        gradients[r * cols + c] += grad;  // Accumulation of gradients
    }
    
    void ZeroGrad() {
        ArrayInitialize(gradients, 0.0);  // Zeroing before a new iteration
    }
};

This structure allows each layer of the network to automatically accumulate gradients and pass them down to previous layers.

The SiLU function is used to activate neurons, which shows better results on financial data compared to the classic ReLU. It is important to implement not only the function itself, but also its derivative for correct backpropagation.

// SiLU (Swish) activation is smoother than ReLU
double SiLU(double x) { 
    double sig = 1.0 / (1.0 + MathExp(-MathMin(MathMax(x, -50), 50))); 
    return x * sig;
}

double SiLUDerivative(double x) {
    double sig = 1.0 / (1.0 + MathExp(-MathMin(MathMax(x, -50), 50)));
    return sig * (1.0 + x * (1.0 - sig));
}

// Sigmoid for the output layer
double Sigmoid(double x) { 
    return 1.0 / (1.0 + MathExp(-MathMin(MathMax(x, -50), 50))); 
}

double SigmoidDerivative(double x) {
    double s = Sigmoid(x);
    return s * (1.0 - s);
}

Limiting input values to the range of -50 to 50 prevents overflow when calculating the exponent, which is critical for the stability of training on noisy financial data.

Simple gradient descent does not work well with financial time series due to the high level of noise. Therefore, an Adam optimizer is implemented that adaptively adjusts the learning rate for each parameter based on the gradient history.

struct AdamOptimizer {
    Matrix m, v;  // Momentum and speed
    double beta1, beta2, eps;
    int step;
    
    void Init(int rows, int cols) {
        m.Init(rows, cols);
        v.Init(rows, cols);
        beta1 = 0.9; beta2 = 0.999; eps = 1e-8; step = 0;
    }
    
    void Update(Matrix &weights, double lr, double weight_decay = 0.01) {
        step++;
        for(int i = 0; i < weights.rows; i++) {
            for(int j = 0; j < weights.cols; j++) {
                double grad = weights.GetGrad(i, j) + weight_decay * weights.Get(i, j);
                
                // Update moments
                double m_val = beta1 * m.Get(i, j) + (1 - beta1) * grad;
                double v_val = beta2 * v.Get(i, j) + (1 - beta2) * grad * grad;
                
                m.Set(i, j, m_val); v.Set(i, j, v_val);
                
                // Bias correction
                double m_hat = m_val / (1 - MathPow(beta1, step));
                double v_hat = v_val / (1 - MathPow(beta2, step));
                
                // Update weights
                double update = lr * m_hat / (MathSqrt(v_hat) + eps);
                weights.Set(i, j, weights.Get(i, j) - update);
            }
        }
        weights.ZeroGrad();  // Clear gradients after updating
    }
};



Forward and Backward passes in the N-BEATS block

Each N-BEATS block performs a complex sequence of operations. It is critical to store intermediate values for the backward pass:

struct NBeatsBlock {
    Matrix fc1_w, fc1_b, fc2_w, fc2_b, theta_projection;
    AdamOptimizer fc1_opt, fc2_opt, theta_opt;
    
    // Intermediate values for backprop
    Matrix fc1_input, fc1_output, fc2_input, fc2_output;
    
    void Forward(Matrix &input_patch, Matrix &backcast, Matrix &forecast) {
        // Save the input for backprop
        fc1_input.Copy(input_patch);
        
        // FC1 layer
        fc1_output.Init(1, HIDDEN_SIZE);
        for(int j = 0; j < HIDDEN_SIZE; j++) {
            double s = 0.0;
            for(int i = 0; i < input_patch.cols; i++) 
                s += input_patch.Get(0, i) * fc1_w.Get(i, j);
            s += fc1_b.Get(0, j);
            fc1_output.Set(0, j, s);  // Save BEFORE activation
        }
        
        // Activation with saving for backprop
        fc2_input.Init(1, HIDDEN_SIZE);
        for(int j = 0; j < HIDDEN_SIZE; j++) {
            fc2_input.Set(0, j, SiLU(fc1_output.Get(0, j)));
        }
        
        // Similar for FC2 layer...
        // Theta projection and backcast/forecast generation
    }
    
    void Backward(Matrix &backcast_grad, Matrix &forecast_grad) {
        // Backprop starts with output gradients
        // Combine backcast and forecast gradients
        Matrix theta_grad;
        theta_grad.Init(1, theta_projection.cols);
        
        // Calculate theta projection gradients
        for(int i = 0; i < theta_projection.cols; i++) {
            double grad = 0.0;
            if(i < backcast_size) {
                grad += backcast_grad.GetGrad(0, i);
            } else {
                grad += forecast_grad.GetGrad(0, i - backcast_size);
            }
            theta_grad.SetGrad(0, i, grad);
        }
        
        // Backprop via theta projection к FC2
        Matrix fc2_grad;
        fc2_grad.Init(1, HIDDEN_SIZE);
        for(int i = 0; i < HIDDEN_SIZE; i++) {
            double grad_sum = 0.0;
            for(int j = 0; j < theta_projection.cols; j++) {
                grad_sum += theta_grad.GetGrad(0, j) * theta_projection.Get(i, j);
                // Accumulate gradients for the theta_projection weights
                theta_projection.AddGrad(i, j, theta_grad.GetGrad(0, j) * fc2_output.Get(0, i));
            }
            fc2_grad.SetGrad(0, i, grad_sum);
        }
        
        // Backprop via SiLU activation
        Matrix fc2_input_grad;
        fc2_input_grad.Init(1, HIDDEN_SIZE);
        for(int i = 0; i < HIDDEN_SIZE; i++) {
            double grad = fc2_grad.GetGrad(0, i) * SiLUDerivative(fc1_output.Get(0, i));
            fc2_input_grad.SetGrad(0, i, grad);
        }
        
        // Backprop through FC1 layer to inputs
        // ... similar calculations for all layers
    }
};



Quantile loss function

To predict uncertainty, we use quantile loss instead of the usual MSE:

void Train(double &time_series[], double target) {
    step_count++;
    
    // Forward pass for all quantiles
    double predictions[QUANTILE_LEVELS];
    Predict(time_series, predictions);
    
    // Calculate quantile losses
    for(int q = 0; q < QUANTILE_LEVELS; q++) {
        double pred = predictions[q];
        double error = target - pred;
        double quantile = quantiles[q];  // 0.1, 0.5, 0.9
        
        // Quantile loss
        double loss_weight = (error >= 0) ? quantile : (1.0 - quantile);
        double focal_weight = class_weights.GetFocalWeight(target, pred);
        
        double loss = focal_weight * loss_weight * MathAbs(error);
        
        // Gradient for quantile loss
        double grad_wrt_pred = focal_weight * loss_weight * ((error >= 0) ? -1.0 : 1.0);
        double grad_wrt_logit = grad_wrt_pred * SigmoidDerivative(pred);
        
        // Backprop to output weights
        for(int h = 0; h < HIDDEN_SIZE; h++) {
            double grad = grad_wrt_logit * pooled_features[h];
            output_proj[q].SetGrad(h, 0, grad);
        }
    }
    
    // Run a backward pass through the entire network
    BackpropagateToAllLayers();
    
    // Update the weights of all layers
    UpdateAllWeights();
}

Input data quality is critical to the success of any machine learning model, but for trading algorithms it can mean the difference between preserving and blowing up an account.



Multivariate features

Instead of a simple sequence of closing prices, we use a rich set of features:

void PrepareInputData(MqlRates &rates[], double &input_data[], int input_bars) {
    ArrayResize(input_data, input_bars * 2);  // 2 features per bar
    
    for(int i = 0; i < input_bars; i++) {
        if(i < ArraySize(rates) - 1) {
            // Feature 1: Normalized price change
            double price_change = (rates[i].close - rates[i].open) / rates[i].open;
            input_data[i * 2] = MathMax(-1.0, MathMin(1.0, price_change * 100));
            
            // Feature 2: Logarithmically normalized volume
            double vol_ratio = (rates[i].tick_volume > 0) ? 
                MathLog(1.0 + rates[i].tick_volume / 1000.0) : 0.0;
            input_data[i * 2 + 1] = MathMax(0.0, MathMin(1.0, vol_ratio / 10.0));
        } else {
            input_data[i * 2] = 0.0;
            input_data[i * 2 + 1] = 0.0;
        }
    }
}

Clipping values into reasonable ranges prevents extreme outliers from affecting model training.



Robust normalization against noise

Financial data is full of anomalies: price spikes, technical glitches, abnormal volumes. A simple min-max normalization can be broken by a single outlier:

void RobustNormalization(double &data[], int window_size = 200) {
    int data_size = ArraySize(data);
    
    for(int i = window_size; i < data_size; i++) {
        // Calculate robust statistics for a sliding window
        double window_data[];
        ArrayResize(window_data, window_size);
        
        for(int j = 0; j < window_size; j++) {
            window_data[j] = data[i - window_size + j];
        }
        
        // Median instead of mean (robust to outliers)
        ArraySort(window_data);
        double median = window_data[window_size / 2];
        
        // MAD (Median Absolute Deviation) instead of standard deviation
        double deviations[];
        ArrayResize(deviations, window_size);
        for(int j = 0; j < window_size; j++) {
            deviations[j] = MathAbs(window_data[j] - median);
        }
        ArraySort(deviations);
        double mad = deviations[window_size / 2];
        
        // Robust normalization
        if(mad > 1e-8) {
            data[i] = MathMax(-3.0, MathMin(3.0, (data[i] - median) / mad));
        }
    }
}

Using the median and MAD instead of the mean and standard deviation makes the normalization robust to outliers.



Anomaly detection and handling

The system should automatically detect abnormal market conditions:

bool DetectMarketAnomaly(MqlRates &rates[], int lookback = 50) {
    if(ArraySize(rates) < lookback) return false;
    
    // Calculate volatility for the period
    double volatilities[];
    ArrayResize(volatilities, lookback);
    
    for(int i = 1; i < lookback; i++) {
        double change = MathAbs(rates[i].close - rates[i-1].close) / rates[i-1].close;
        volatilities[i] = change;
    }
    
    ArraySort(volatilities);
    double median_vol = volatilities[lookback / 2];
    double vol_95 = volatilities[(int)(lookback * 0.95)];
    
    // Current volatility
    double current_vol = MathAbs(rates[0].close - rates[1].close) / rates[1].close;
    
    // Anomaly if current volatility exceeds 95th percentile 2+ times
    return (current_vol > vol_95 * 2.0);
}
If anomalies are detected, the trading system may temporarily reduce position sizes or pause trading.

Traditional approaches to forecasting provide a single point estimate - the price will be such-and-such. But for trading, it is critical to understand the uncertainty of the forecast. This is why our implementation of N-BEATS uses quantile forecasting.

Instead of one value, the system produces three estimates: a pessimistic scenario (10th quantile), a realistic forecast (median), and an optimistic scenario (90th quantile). The difference between quantiles shows the confidence of the model in the forecast.

To train quantile forecasts, a special loss function is used that penalizes under- and over-estimation differently depending on the target quantile.

void ProcessQuantilePredictions(double &predictions[]) {
    double q10 = predictions[0];  // Pessimistic scenario
    double q50 = predictions[1];  // Median forecast
    double q90 = predictions[2];  // Optimistic scenario
    
    // Calculate the model uncertainty
    double uncertainty = q90 - q10;
    double confidence = 1.0 - MathMin(1.0, uncertainty / 0.4);  // Normalize [0,1]
    
    // Dynamically adjust position size
    double base_lot = CalculateBaseLotSize();
    double adjusted_lot = base_lot * confidence;
    
    if(q50 > 0.65 && uncertainty < 0.2) {
        // High growth confidence, low uncertainty
        OpenPosition(ORDER_TYPE_BUY, adjusted_lot * 1.5, "High confidence");
    }
    else if(q50 > 0.6 && uncertainty < 0.3) {
        // Moderate confidence in growth
        OpenPosition(ORDER_TYPE_BUY, adjusted_lot, "Medium confidence");
    }
    else if(q10 > 0.55) {
        // Even the pessimistic scenario shows growth
        OpenPosition(ORDER_TYPE_BUY, adjusted_lot * 0.7, "Conservative long");
    }
    
    Print("Uncertainty: ", DoubleToString(uncertainty, 3), 
          " | Confidence: ", DoubleToString(confidence, 3),
          " | Adjusted lot: ", DoubleToString(adjusted_lot, 2));
}



Focal Loss: Combating class imbalance

Financial markets are inherently unbalanced, with periods of calm trading interspersed with rare but strong movements. The standard loss function cannot handle this problem, so we use Focal Loss:

struct ClassWeights {
    double strong_bull, weak_bull, neutral, weak_bear, strong_bear;
    
    void UpdateWeights(int &counts[]) {
        int total = counts[0] + counts[1] + counts[2] + counts[3] + counts[4];
        if(total == 0) return;
        
        // Inverted weights - rare classes get more weight
        strong_bear = (counts[0] > 0) ? (double)total / (5 * counts[0]) : 1.0;
        weak_bear = (counts[1] > 0) ? (double)total / (5 * counts[1]) : 1.0;
        neutral = (counts[2] > 0) ? (double)total / (5 * counts[2]) : 1.0;
        weak_bull = (counts[3] > 0) ? (double)total / (5 * counts[3]) : 1.0;
        strong_bull = (counts[4] > 0) ? (double)total / (5 * counts[4]) : 1.0;
    }
    
    double GetFocalWeight(double target, double pred) {
        double alpha = GetClassWeight(target);
        double pt = (target > 0.5) ? pred : (1.0 - pred);
        
        // Focal Loss: focuses on difficult samples
        return alpha * MathPow(MathMax(1.0 - pt, 1e-8), FOCAL_GAMMA);
    }
};

Focal Loss automatically increases attention to rare but important market events — the ones where you can make the most money.



Uncertainty-based adaptive stop-loss

The traditional approach to stop losses is primitive: a fixed number of points from the entry. Our system uses predicted uncertainty:

double CalculateAdaptiveStopLoss(double entry_price, ENUM_ORDER_TYPE order_type, 
                                double uncertainty, double base_sl_points = 500) {
    // Increase stop loss when uncertainty is high
    double uncertainty_multiplier = 1.0 + uncertainty * 2.0;  // [1.0, 3.0]
    double adaptive_sl_points = base_sl_points * uncertainty_multiplier;
    
    // Take into account the current volatility
    double atr = CalculateATR(14);  // Average True Range for 14 periods
    double volatility_adjustment = MathMax(0.5, MathMin(2.0, atr / (Point() * 100)));
    
    adaptive_sl_points *= volatility_adjustment;
    
    double sl_price;
    if(order_type == ORDER_TYPE_BUY) {
        sl_price = entry_price - adaptive_sl_points * Point();
    } else {
        sl_price = entry_price + adaptive_sl_points * Point();
    }
    
    Print("Adaptive SL: base=", base_sl_points, 
          " uncertainty_mult=", DoubleToString(uncertainty_multiplier, 2),
          " volatility_adj=", DoubleToString(volatility_adjustment, 2),
          " final=", DoubleToString(adaptive_sl_points, 0));
    
    return sl_price;
}

double CalculateATR(int period) {
    double atr_values[];
    if(CopyBuffer(iATR(Symbol(), Period(), period), 0, 0, 1, atr_values) <= 0) {
        return Point() * 100;  // Fallback value
    }
    return atr_values[0];
}


Integration with MetaTrader 5 trading logic

Creating a powerful neural network is only half the battle. The main goal is to integrate it with the trading platform so that it operates in real time, processes every tick, and makes trading decisions in milliseconds.

MetaTrader 5 operates on an event-driven model. Every tick and every price change calls the OnTick() function. But running heavy neural network calculations on every tick is inefficient — a smart caching system is needed:

datetime g_last_bar_time = 0;
double   g_cached_prediction = 0.5;
bool     g_prediction_valid = false;

bool IsNewBar() {
    datetime current_bar_time = iTime(Symbol(), Period(), 0);
    if(current_bar_time != g_last_bar_time) {
        g_last_bar_time = current_bar_time;
        g_prediction_valid = false;  // Cache is out of date
        return true;
    }
    return false;
}

void OnTick() {
    if(!g_net_initialized) return;
    
    // Perform light operations on every tick
    if(UseTrailingStop) UpdateTrailingStops();
    
    // Heavy calculations only on the new bar
    if(IsNewBar()) {
        double prediction = GetAIPrediction();  // Computationally expensive operation
        g_cached_prediction = prediction;
        g_prediction_valid = true;
        
        ProcessTradingSignals(prediction);
    }
    
    // Check emergency exits on every tick
    if(g_prediction_valid) {
        CheckEmergencyExits(g_cached_prediction);
    }
}
Hysteresis signal system

The simple logic of "above the threshold - buy, below - sell" creates the problem of signal chatter. When the forecast fluctuates around the threshold value, the system will continuously open and close positions. The solution is hysteresis:

void ProcessTradingSignalsWithHysteresis(double prediction) {
    static double last_prediction = 0.5;
    static datetime last_signal_time = 0;
    
    // Minimum interval between signals
    if(TimeCurrent() - last_signal_time < PeriodSeconds(Period()) * 2) return;
    
    int current_positions = CountPositions();
    bool currently_long = HasLongPosition();
    bool currently_short = HasShortPosition();
    
    // Different thresholds for entry and exit (hysteresis)
    double long_entry_threshold = 0.70;
    double long_exit_threshold = 0.55;
    double short_entry_threshold = 0.30;
    double short_exit_threshold = 0.45;
    
    // Logic with hysteresis
    if(!currently_long && prediction > long_entry_threshold && 
       last_prediction <= long_entry_threshold) {
        
        if(currently_short) CloseAllPositions("Signal reversal");
        
        OpenPosition(ORDER_TYPE_BUY, prediction);
        last_signal_time = TimeCurrent();
        Print("🚀 LONG ENTRY: ", DoubleToString(prediction, 3));
    }
    else if(currently_long && prediction < long_exit_threshold && 
            last_prediction >= long_exit_threshold) {
        
        CloseAllPositions("Long exit signal");
        Print("📤 LONG EXIT: ", DoubleToString(prediction, 3));
    }
    
    // Similar logic for short positions...
    
    last_prediction = prediction;
}
Continuous training in production

Markets are constantly evolving. A model that worked well a month ago may be useless today. Therefore, a system of continuous learning is needed:

struct ContinuousLearning {
    datetime last_retrain_time;
    double performance_buffer[];
    int performance_window;
    double performance_threshold;
    
    void Init() {
        last_retrain_time = TimeCurrent();
        performance_window = 100;  // Track the last 100 trades
        performance_threshold = 0.45;  // Degradation threshold
        ArrayResize(performance_buffer, performance_window);
    }
    
    void AddTradeResult(double prediction, double actual_outcome) {
        // Shift the buffer
        for(int i = 0; i < performance_window - 1; i++) {
            performance_buffer[i] = performance_buffer[i + 1];
        }
        
        // Add a new result (1.0 = accurate prediction, 0.0 = completely wrong)
        double accuracy = 1.0 - MathAbs(prediction - actual_outcome);
        performance_buffer[performance_window - 1] = accuracy;
        
        // Check if retraining is necessary
        if(ShouldRetrain()) {
            ScheduleRetraining();
        }
    }
    
    bool ShouldRetrain() {
        // Condition 1: Enough time has passed
        bool time_condition = (TimeCurrent() - last_retrain_time) > 24 * 3600;
        
        // Condition 2: Performance has decreased
        double avg_performance = 0.0;
        for(int i = 0; i < performance_window; i++) {
            avg_performance += performance_buffer[i];
        }
        avg_performance /= performance_window;
        bool performance_condition = avg_performance < performance_threshold;
        
        return time_condition || performance_condition;
    }
    
    void ScheduleRetraining() {
        Print("🔄 Scheduling model retraining...");
        Print("Average performance: ", DoubleToString(GetAveragePerformance(), 3));
        
        // Retrain in a separate thread (simulate)
        RetrainModel();
        last_retrain_time = TimeCurrent();
    }
};

ContinuousLearning g_learning_system;

N-BEATS is a complex architecture with many parameters. In a production environment, every millisecond of latency can cost money, so performance optimization is critical.

Memory management

Each matrix in the network can contain thousands of parameters. Without proper memory management, the system will quickly run out of resources:

struct MemoryManager {
    Matrix temp_matrices[];
    int allocated_count;
    int max_temp_matrices;
    
    void Init() {
        max_temp_matrices = 100;
        ArrayResize(temp_matrices, max_temp_matrices);
        allocated_count = 0;
    }
    
    Matrix* AllocateTempMatrix(int rows, int cols) {
        if(allocated_count >= max_temp_matrices) {
            Print("⚠️ Temporary matrix pool exhausted!");
            return NULL;
        }
        
        temp_matrices[allocated_count].Init(rows, cols);
        return &temp_matrices[allocated_count++];
    }
    
    void ResetTempPool() {
        // Quick "release" without real deallocation
        allocated_count = 0;
    }
    
    void PrintMemoryUsage() {
        int total_elements = 0;
        for(int i = 0; i < allocated_count; i++) {
            total_elements += temp_matrices[i].rows * temp_matrices[i].cols;
        }
        
        double memory_mb = total_elements * sizeof(double) / (1024.0 * 1024.0);
        Print("Memory usage: ", DoubleToString(memory_mb, 2), " MB");
        Print("Active matrices: ", allocated_count);
    }
};

MemoryManager g_memory_manager;
Critical path profiling

It is important to know where exactly your execution time is spent:

struct Profiler {
    string operation_names[];
    ulong operation_times[];
    int operation_counts[];
    int num_operations;
    
    void Init() {
        num_operations = 0;
        ArrayResize(operation_names, 50);
        ArrayResize(operation_times, 50);
        ArrayResize(operation_counts, 50);
    }
    
    void StartTimer(string operation) {
        // MQL5 does not have high-precision timers, use GetMicrosecondCount()
        ulong start_time = GetMicrosecondCount();
        
        // Search for an existing operation or create a new one
        int index = FindOrCreateOperation(operation);
        operation_times[index] = start_time;
    }
    
    void EndTimer(string operation) {
        ulong end_time = GetMicrosecondCount();
        int index = FindOperation(operation);
        
        if(index >= 0) {
            ulong elapsed = end_time - operation_times[index];
            operation_times[index] = elapsed;  // Reuse to store time
            operation_counts[index]++;
        }
    }
    
    void PrintReport() {
        Print("=== PERFORMANCE REPORT ===");
        for(int i = 0; i < num_operations; i++) {
            double avg_time_ms = operation_times[i] / (double)operation_counts[i] / 1000.0;
            Print(operation_names[i], ": ", DoubleToString(avg_time_ms, 3), " ms avg, ", 
                  operation_counts[i], " calls");
        }
    }
};

Profiler g_profiler;

// Usage:
void OptimizedPredict(double &time_series[]) {
    g_profiler.StartTimer("PatchEmbedding");
    // ... patch embedding code
    g_profiler.EndTimer("PatchEmbedding");
    
    g_profiler.StartTimer("StackForward");
    // ... forward pass via stacks
    g_profiler.EndTimer("StackForward");
}

Profiling helps us identify bottlenecks and focus our optimization efforts where they really matter.

A trading robot with a neural network is not just code you can run and forget. It is a complex system that requires constant monitoring, diagnostics and adjustments. Without the right monitoring system, even the most perfect architecture can silently degrade and start losing money.

Real-time monitoring of forecast quality

The first line of defense is continuous monitoring of forecast accuracy in real time:

struct PredictionMonitor {
    double accuracy_buffer[];
    double confidence_buffer[];
    double actual_outcomes[];
    int buffer_size, current_index;
    datetime last_evaluation;
    
    void Init(int size = 1000) {
        buffer_size = size;
        current_index = 0;
        ArrayResize(accuracy_buffer, buffer_size);
        ArrayResize(confidence_buffer, buffer_size);
        ArrayResize(actual_outcomes, buffer_size);
        ArrayInitialize(accuracy_buffer, 0.5);
        last_evaluation = TimeCurrent();
    }
    
    void RecordPrediction(double prediction, double confidence) {
        confidence_buffer[current_index] = confidence;
        // Save the forecast for future evaluation
        // actual_outcomes will be filled later when the result is known
    }
    
    void RecordOutcome(double actual_movement) {
        if(current_index >= buffer_size) return;
        
        actual_outcomes[current_index] = actual_movement;
        
        // Calculate the forecast accuracy
        double predicted = confidence_buffer[current_index];
        double accuracy = 1.0 - MathAbs(predicted - actual_movement);
        accuracy_buffer[current_index] = accuracy;
        
        current_index = (current_index + 1) % buffer_size;
        
        // Periodically display statistics
        if(TimeCurrent() - last_evaluation > 3600) {  // Every hour
            PrintAccuracyReport();
            last_evaluation = TimeCurrent();
        }
    }
    
    void PrintAccuracyReport() {
        double total_accuracy = 0.0, total_confidence = 0.0;
        int valid_samples = 0;
        
        for(int i = 0; i < buffer_size; i++) {
            if(accuracy_buffer[i] > 0) {
                total_accuracy += accuracy_buffer[i];
                total_confidence += confidence_buffer[i];
                valid_samples++;
            }
        }
        
        if(valid_samples > 10) {
            double avg_accuracy = total_accuracy / valid_samples;
            double avg_confidence = total_confidence / valid_samples;
            
            Print("=== PREDICTION QUALITY REPORT ===");
            Print("Average accuracy: ", DoubleToString(avg_accuracy * 100, 2), "%");
            Print("Average confidence: ", DoubleToString(avg_confidence * 100, 2), "%");
            Print("Valid samples: ", valid_samples);
            
            // Degradation warning
            if(avg_accuracy < 0.5) {
                Print("⚠️ WARNING: Model accuracy below random baseline!");
            }
            if(avg_accuracy < avg_confidence - 0.1) {
                Print("⚠️ WARNING: Model overconfident - accuracy vs confidence gap!");
            }
        }
    }
    
    double GetRecentAccuracy(int lookback = 100) {
        double sum = 0.0;
        int count = 0;
        
        for(int i = 0; i < MathMin(lookback, buffer_size); i++) {
            int idx = (current_index - i - 1 + buffer_size) % buffer_size;
            if(accuracy_buffer[idx] > 0) {
                sum += accuracy_buffer[idx];
                count++;
            }
        }
        
        return (count > 0) ? sum / count : 0.5;
    }
};

PredictionMonitor g_monitor;
Concept drift detection

Financial markets are constantly evolving. Patterns that worked a month ago may no longer be relevant. The system should automatically detect such changes:

struct ConceptDriftDetector {
    double recent_performance[];
    double historical_baseline;
    int window_size;
    bool drift_detected;
    datetime last_drift_check;
    
    void Init(int window = 200) {
        window_size = window;
        ArrayResize(recent_performance, window_size);
        historical_baseline = 0.55;  // Base model accuracy
        drift_detected = false;
        last_drift_check = TimeCurrent();
    }
    
    void AddPerformancePoint(double accuracy) {
        // Move the window
        for(int i = 0; i < window_size - 1; i++) {
            recent_performance[i] = recent_performance[i + 1];
        }
        recent_performance[window_size - 1] = accuracy;
        
        // Check drift every 4 hours
        if(TimeCurrent() - last_drift_check > 4 * 3600) {
            CheckForDrift();
            last_drift_check = TimeCurrent();
        }
    }
    
    void CheckForDrift() {
        // Calculate the average performance in the window
        double window_mean = 0.0;
        int valid_count = 0;
        
        for(int i = 0; i < window_size; i++) {
            if(recent_performance[i] > 0) {
                window_mean += recent_performance[i];
                valid_count++;
            }
        }
        
        if(valid_count < 50) return;  // Not enough data
        
        window_mean /= valid_count;
        
        // Statistical test for significant deviation
        double variance = 0.0;
        for(int i = 0; i < window_size; i++) {
            if(recent_performance[i] > 0) {
                double diff = recent_performance[i] - window_mean;
                variance += diff * diff;
            }
        }
        variance /= (valid_count - 1);
        double std_error = MathSqrt(variance / valid_count);
        
        // Z test to determine the significance of the deviation
        double z_score = (window_mean - historical_baseline) / std_error;
        bool significant_drift = MathAbs(z_score) > 2.58;  // 99% confidence level
        
        if(significant_drift && !drift_detected) {
            drift_detected = true;
            Print("🚨 CONCEPT DRIFT DETECTED!");
            Print("Historical baseline: ", DoubleToString(historical_baseline, 3));
            Print("Recent performance: ", DoubleToString(window_mean, 3));
            Print("Z-score: ", DoubleToString(z_score, 2));
            Print("Recommendation: Schedule model retraining");
            
            // Automatically start retraining
            TriggerEmergencyRetraining();
        }
        else if(!significant_drift && drift_detected) {
            drift_detected = false;
            Print("✅ Concept drift resolved - model performance stabilized");
        }
    }
    
    void UpdateBaseline(double new_baseline) {
        historical_baseline = new_baseline;
        Print("📊 Performance baseline updated to ", DoubleToString(new_baseline, 3));
    }
};

ConceptDriftDetector g_drift_detector;
Alert and automatic response system

Critical situations require immediate response, even if the trader is asleep or unavailable:

struct AlertSystem {
    enum ALERT_LEVEL {
        INFO = 0,
        WARNING = 1,
        CRITICAL = 2,
        EMERGENCY = 3
    };
    
    struct Alert {
        ALERT_LEVEL level;
        string message;
        datetime timestamp;
        bool acknowledged;
    };
    
    Alert active_alerts[];
    int max_alerts;
    datetime last_notification;
    
    void Init() {
        max_alerts = 100;
        ArrayResize(active_alerts, max_alerts);
        last_notification = 0;
    }
    
    void RaiseAlert(ALERT_LEVEL level, string message) {
        // Look for a free slot for an alert
        int slot = -1;
        for(int i = 0; i < max_alerts; i++) {
            if(active_alerts[i].timestamp == 0) {
                slot = i;
                break;
            }
        }
        
        if(slot == -1) {
            // Replace the oldest alert
            datetime oldest = TimeCurrent();
            for(int i = 0; i < max_alerts; i++) {
                if(active_alerts[i].timestamp < oldest) {
                    oldest = active_alerts[i].timestamp;
                    slot = i;
                }
            }
        }
        
        // Create an alert
        active_alerts[slot].level = level;
        active_alerts[slot].message = message;
        active_alerts[slot].timestamp = TimeCurrent();
        active_alerts[slot].acknowledged = false;
        
        // Display in the log with the corresponding prefixes
        string prefix = GetAlertPrefix(level);
        Print(prefix, " ", message);
        
        // Critical alerts require immediate action
        if(level >= CRITICAL) {
            HandleCriticalAlert(message);
        }
        
        // Limit the frequency of notifications
        if(TimeCurrent() - last_notification > 300) {  // No more than once every 5 minutes
            SendNotification(level, message);
            last_notification = TimeCurrent();
        }
    }
    
    string GetAlertPrefix(ALERT_LEVEL level) {
        switch(level) {
            case INFO: return "ℹ️ INFO:";
            case WARNING: return "⚠️ WARNING:";
            case CRITICAL: return "🚨 CRITICAL:";
            case EMERGENCY: return "🆘 EMERGENCY:";
            default: return "❓ UNKNOWN:";
        }
    }
    
    void HandleCriticalAlert(string message) {
        // Automatic actions for critical alerts
        if(StringFind(message, "accuracy") >= 0) {
            // Accuracy issues - reducing position size
            ReducePositionSizes(0.5);
        }
        else if(StringFind(message, "memory") >= 0) {
            // Memory issues - forced cleanup
            g_memory_manager.ResetTempPool();
        }
        else if(StringFind(message, "drift") >= 0) {
            // Conceptual drift - stop trading until retraining
            DisableTrading("Concept drift detected");
        }
    }
    
    void SendNotification(ALERT_LEVEL level, string message) {
        // In a real system this could be:
        // - Send email via web requests
        // - Push notifications via Telegram Bot API
        // - SMS via external services
        
        string notification = StringFormat("[%s] %s: %s", 
            TimeToString(TimeCurrent()), 
            EnumToString(level), 
            message);
        
        // For demonstration purposes, we simply output it to the terminal
        Print("📱 NOTIFICATION SENT: ", notification);
    }
};

AlertSystem g_alerts;

After developing the N-BEATS system, it was time to test its effectiveness in real trading conditions. MetaTrader 5 provides professional testing tools, which we use.


N-BEATS robot test results

Unfortunately, unlike previous working models in this series - Mamba and PatchTST - we were unable to generate a profit on the test segment from January 2025 to August 2025 despite the promising architecture:

Studying dozens of articles on the algorithm and creating a second version of the EA did not really improve the situation:

Despite the technical complexity and elegance of the implementation, testing on historical data for the period January–August 2025 showed the system's inability to generate stable profits. Unlike previous successful Mamba and PatchTST architectures, N-BEATS failed to perform effectively in real trading conditions.


Conclusion

The conducted study showed that direct adaptation of the N-BEATS architecture to the problem of forecasting on financial time series does not provide the expected results. The main reasons were the high noise level of forex data, the lack of consideration of market microstructure, and the mismatch between computational costs and the achieved trading improvements.

Nevertheless, the project has significant practical value. An infrastructure for experimenting with neural networks in MQL5 was created, backpropagation was implemented from scratch, and risk management tools based on uncertainty were developed. Additionally, monitoring and diagnostic systems have been implemented, including concept drift detection and automatic response to model degradation, which is of interest to a wide range of machine learning-based trading solutions.

The presented code can serve as a basis for further research and development of specialized algorithms that more accurately reflect the characteristics of financial markets. The findings highlight the need to adapt machine learning architectures and methodologies to the specifics of market dynamics and confirm that success in this field requires a combination of engineering solutions and a deep understanding of economic processes.

File name File usage

NBeats_TimeSeriesNet.mqh

The first version of the model. Place in the /Include folder.

NBeats_TimeSeriesNetV2.mqh

The second version of the model. Place in the /Include folder.

NBeats_EA.mq5

The first version of the EA. Place in the /Experts folder.

NBeats_EAV2.mq5

The second version of the EA. Place in the /Experts folder.

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

Attached files |
NBeats_EA.mq5 (29.3 KB)
NBeats_EAV2.mq5 (53.44 KB)
Last comments | Go to discussion (2)
Aleksey Vyazmikin
Aleksey Vyazmikin | 3 Sep 2025 at 22:30
Could you please explain in more detail what‘Conceptual Drift Detection’ is – what is the idea behind it?
secret
secret | 3 Sep 2025 at 23:42
Oh, those GPT articles!)
Features of Custom Indicators Creation Features of Custom Indicators Creation
Creation of Custom Indicators in the MetaTrader trading system has a number of features.
From Option Chain to 3D Volatility Surface in MetaTrader 5 From Option Chain to 3D Volatility Surface in MetaTrader 5
This article walks through creating an MT5 indicator that ingests option chains from native symbols or CSV, inverts prices to implied volatility via a hybrid Newton–Raphson/bisection method, and assembles a clean strike–expiry grid. It then renders a shaded, rotatable 3D surface with the platform's DirectX layer, enabling clear, in-terminal analysis of skew and term structure using live or file-based data.
Features of Experts Advisors Features of Experts Advisors
Creation of expert advisors in the MetaTrader trading system has a number of features.
Interactive Supply and Demand Zone Manager in MQL5 (Part III): Zone Analysis, Stateful Interaction, and Pending Event Management Interactive Supply and Demand Zone Manager in MQL5 (Part III): Zone Analysis, Stateful Interaction, and Pending Event Management
We extend the stateful supply and demand framework for MetaTrader 5 with a quantitative admission model and a dedicated interaction engine. Candidate zones are scored by structural symmetry, volume participation, and ATR‑normalized displacement, then classified into objective tiers. Admitted zones follow a deterministic lifecycle that tracks first touch, validates bounces, or confirms breakouts, with full telemetry for analysis and reproducibility.