N-BEATS Network-Based Forex EA
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 managementEach 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 qualityThe 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
Warning: All rights to these materials are reserved by MetaQuotes Ltd. Copying or reprinting of these materials in whole or in part is prohibited.
This article was written by a user of the site and reflects their personal views. MetaQuotes Ltd is not responsible for the accuracy of the information presented, nor for any consequences resulting from the use of the solutions, strategies or recommendations described.
Features of Custom Indicators Creation
From Option Chain to 3D Volatility Surface in MetaTrader 5
Features of Experts Advisors
Interactive Supply and Demand Zone Manager in MQL5 (Part III): Zone Analysis, Stateful Interaction, and Pending Event Management
- Free trading apps
- Over 8,000 signals for copying
- Economic news for exploring financial markets
You agree to website policy and terms of use