Русский
preview
Bidirectional LSTM and Quantum Computing for Predicting the Direction of Price Movement

Bidirectional LSTM and Quantum Computing for Predicting the Direction of Price Movement

MetaTrader 5Integration |
196 0
Yevgeniy Koshtenko
Yevgeniy Koshtenko

Introduction: When Quantum Mechanics Helps Predict the Market

Imagine that, before the next price movement, the market is, as it were, “considering” many possible continuation scenarios at once: a strong upward impulse, a slow slide downward, a sharp reversal, a continuation of the sideways market, and so on. Classical models see only what has already happened in the historical data. They look for recurring sequences of closing prices, volumes, and indicators. But they almost never have direct access to how “concentrated” or “spread out” these possible scenarios were immediately before the market chose one of them.

Here we are attempting to model this “structure of uncertainty” using the mathematical apparatus of quantum mechanics — not because the market is physically quantum, but because quantum formalism provides very convenient and powerful tools for working with superpositions of probabilities and the correlations between them.

An important clarification right away: we are not using any real quantum computer, and there is absolutely no quantum advantage here in the computational-complexity sense. We simply take a very simple, fixed, non-trainable quantum circuit and use it as an exotic nonlinear transformer that maps a small window of price data into a set of statistical characteristics of the distribution.

The setup is as follows: three qubits → three RY rotations, with angles that depend on the mean returns, volatility, and range of the most recent window → a CNOT chain between adjacent qubits → measurement one thousand times.

From the resulting histogram of the eight possible outcomes, we extract seven different metrics. The clearest and seemingly most useful of them are:

  • the entropy of the probability distribution (the higher it is, the more “equally likely” all scenarios are, and the greater the uncertainty)
  • the maximum probability of any of the eight basis states (how strongly one scenario “outweighs” the others)
  • the number of states whose probability is significantly above chance level (a proxy for the width of the superposition)
  • the degree of “consistency” of the measured outcomes (how close the resulting numerical state values are to one another)

The other metrics (variance, average bit correlation between neighboring qubits, and the absolute number of significant states) usually play a supporting role, but sometimes prove useful in specific market regimes.

Why do we need all this? Classical features and indicators almost always describe what has already materialized in the past. Our metrics, on the other hand, attempt to provide an indirect indication of how “decisive” or “indecisive” the market was before choosing the direction of the next candle.

On the same dataset (EURUSD, H1, ~1500 candles), a pure bidirectional LSTM without these features typically achieves 47–52% accuracy in predicting the direction of the next candle. Adding these seven features on a small test set (~130–160 examples) made it possible to obtain values in the 62–67% range. But first, a caveat: this is a very small and very specific sample. In other time frames, with other instruments, or even simply with a different split seed, the figures can easily drop to +2…+5% or disappear altogether. Therefore, these figures should be treated with extreme caution — they are, for now, merely an interesting engineering experiment, not a proven trading system.

Next up will be the full code, an explanation of the architecture, the class-balancing method, the validation procedure, and — most importantly — a detailed analysis of why such impressive numbers based on a small sample size almost always turn out to be overly optimistic.

Let’s start with the quantum part itself — exactly how these seven numbers are obtained from a price window.

Implementation of the quantum circuit in code:

from qiskit import QuantumCircuit, transpile
from qiskit_aer import AerSimulator
import numpy as np

class QuantumFeatureExtractor:
    def __init__(self, num_qubits: int = 3, shots: int = 1000):
        self.num_qubits = num_qubits
        self.shots = shots
        self.simulator = AerSimulator(method='statevector')
        self.cache = {}
    
    def create_quantum_circuit(self, features: np.ndarray) -> QuantumCircuit:
        qc = QuantumCircuit(self.num_qubits, self.num_qubits)
        
        for i in range(self.num_qubits):
            feature_idx = i % len(features)
            angle = np.clip(np.pi * features[feature_idx], -2*np.pi, 2*np.pi)
            qc.ry(angle, i)
        
        for i in range(self.num_qubits - 1):
            qc.cx(i, i + 1)
        
        qc.measure(range(self.num_qubits), range(self.num_qubits))
        return qc

This circuit is simple. Three qubits, which yield eight possible states — enough to identify basic patterns, but not too many to hinder fast computations. RY gates are applied to each qubit with angles calculated from market data. CNOT gates connect the qubits in series: the first to the second, and the second to the third. Measurements collapse the state and give us a classical bit vector.

This circuit is run on the IBM's Qiskit simulator with 1000 measurements (shots). Why a thousand? It is a balance between accuracy and speed. Fewer shots mean more statistical noise in the results. More shots result in slower performance, but the improvement in accuracy after 1000–2000 measurements is minimal. The simulator uses the statevector method, which means it accurately calculates the quantum state without introducing the noise associated with real quantum hardware. For our purposes, this is ideal.

Now, the function for extracting quantum features:

def extract_quantum_features(self, price_data: np.ndarray) -> dict:
    import hashlib
    data_hash = hashlib.md5(price_data.tobytes()).hexdigest()
    if data_hash in self.cache:
        return self.cache[data_hash]
    
    returns = np.diff(price_data) / (price_data[:-1] + 1e-10)
    features = np.array([
        np.mean(returns),
        np.std(returns),
        np.max(returns) - np.min(returns)
    ])
    features = np.tanh(features)
    
    try:
        qc = self.create_quantum_circuit(features)
        compiled_circuit = transpile(qc, self.simulator, optimization_level=2)
        job = self.simulator.run(compiled_circuit, shots=self.shots)
        result = job.result()
        counts = result.get_counts()
        
        quantum_features = self._compute_quantum_metrics(counts, self.shots)
        self.cache[data_hash] = quantum_features
        return quantum_features
    except Exception as e:
        return self._get_default_features()

Caching based on the window's MD5 hash is practically a must; otherwise, processing the sliding window becomes unacceptably slow.

Calculating metrics:

def _compute_quantum_metrics(self, counts: dict, shots: int) -> dict:
    probabilities = {state: count/shots for state, count in counts.items()}
    
    quantum_entropy = -sum(p * np.log2(p) if p > 0 else 0 
                          for p in probabilities.values())
    
    dominant_state_prob = max(probabilities.values())
    
    threshold = 0.05
    significant_states = sum(1 for p in probabilities.values() if p > threshold)
    superposition_measure = significant_states / (2 ** self.num_qubits)
    
    state_values = [int(state, 2) for state in probabilities.keys()]
    max_value = 2 ** self.num_qubits - 1
    phase_coherence = 1.0 - (np.std(state_values) / max_value) if len(state_values) > 1 else 0.5
    
    entanglement_degree = self._compute_entanglement_from_cnot(probabilities)
    
    mean_state = sum(int(state, 2) * prob for state, prob in probabilities.items())
    quantum_variance = sum((int(state, 2) - mean_state)**2 * prob 
                          for state, prob in probabilities.items())
    
    return {
        'quantum_entropy': quantum_entropy,
        'dominant_state_prob': dominant_state_prob,
        'superposition_measure': superposition_measure,
        'phase_coherence': phase_coherence,
        'entanglement_degree': entanglement_degree,
        'quantum_variance': quantum_variance,
        'num_significant_states': float(significant_states)
    }

A brief overview of each metric, without any unnecessary embellishments:

  • quantum_entropy — a measure of the uniformity of a distribution. High ≈ 3 bits → all scenarios are nearly equally likely. Low → one or two states dominate.
  • dominant_state_prob — how much the most likely outcome stands out.
  • superposition_measure — the fraction of states with probability >5%, relative to the maximum possible number of states.
  • phase_coherence — how "clustered" the numerical values of the obtained states are (from 0 to 7). High — the outcomes are “coordinated” with one another.
  • entanglement_degree — the average probability that bits in neighboring qubits are in the same state. Shows the strength of the linear entanglement introduced by the CNOT gates.
  • quantum_variance — the weighted variance with respect to integer state indices.
  • num_significant_states — simply the absolute number of states above the threshold.

All of these values are simply different ways of looking at the same 8-bin histogram, obtained after passing three simple statistics through a highly nonlinear probabilistic transformation.

Whether they are actually more useful in practice than conventional nonlinear features (such as the kernel trick, random Fourier features, wavelets, etc.) remains a major open question. On our tiny test set, they produced a gain, but how robust that is remains unknown.

def _compute_entanglement_from_cnot(self, probabilities: dict) -> float:
    bit_correlations = []
    for i in range(self.num_qubits - 1):
        correlation = 0.0
        for state, prob in probabilities.items():
            if len(state) > i + 1:
                if state[-(i+1)] == state[-(i+2)]:
                    correlation += prob
        bit_correlations.append(correlation)
    return np.mean(bit_correlations) if bit_correlations else 0.5

Here, we iterate over all pairs of adjacent qubits. For each pair, we examine the measured states and calculate the probability that the bits of these qubits match. Note the indexing from the end of the string, `state[-(i+1)]`: this is because Qiskit returns states in reverse order (qubit 0 is on the right, not on the left). If the bits frequently match, the correlation is high, which indicates a high level of entanglement created by CNOT gates. We take the average across all pairs to obtain an overall measure of entanglement for the system.

Quantum variance follows the standard formula: the sum of the squared deviations from the mean, weighted by probabilities. The mean state is calculated as the weighted sum of the numerical values of the states. Next, for each state, we calculate the square of the deviation from the mean, multiply it by the probability, and sum the results.

These seven quantities — quantum entropy, dominant state, superposition, coherence, entanglement, variance, and number of states — serve as additional inputs for the neural network. They convey information that classical features (price, volume, technical indicators) cannot provide. They describe the structure of market uncertainty, the probability distributions over possible scenarios, the coherence of these scenarios, and their correlations. This offers a kind of window into the market’s uncertainty structure.



Neural Network Architecture: Balancing on the Edge of Overfitting

Quantum features are simply seven additional numbers at each time step. The core component of the system is a bidirectional LSTM that processes a sequence of classical features (normalized returns, log returns, high-low, close-open, and tick volume).

A bidirectional LSTM was chosen for two reasons:

  • the market has temporal memory, and past bars influence the current state;
  • sometimes it is useful to consider how the situation developed “from the end” (that is, to take future context into account within the window, which bidirectional processing does naturally)

Base model code:

import torch
import torch.nn as nn

class QuantumLSTM(nn.Module):
    def __init__(self, input_size: int = 5, quantum_feature_size: int = 7,
                 hidden_size: int = 128, num_layers: int = 3, dropout: float = 0.3):
        super(QuantumLSTM, self).__init__()
        
        self.lstm = nn.LSTM(
            input_size=input_size,
            hidden_size=hidden_size,
            num_layers=num_layers,
            dropout=dropout,
            batch_first=True,
            bidirectional=True
        )

The `bidirectional=True` parameter doubles the number of output neurons. If `hidden_size` = 128, then the output of the bidirectional LSTM will be 256 (128 forward + 128 backward). This increases the number of parameters, but gives the model greater expressiveness.

The second issue is training stability. Deep neural networks are prone to gradient issues. If the weights are initialized poorly, or if the data are on different scales, the gradients may explode (become enormous) or vanish (become microscopic). Exploding gradients lead to unstable training, where the loss fluctuates erratically. Vanishing gradients mean that the lower layers of the network barely learn.

Batch Normalization solves this problem. After each linear layer, we normalize the activations across the current batch. We calculate the mean and standard deviation of activations in the batch, subtract the mean, and divide by the standard deviation. This ensures that each layer receives data with a mean of zero and a variance of one. Training becomes smoother and more predictable.

self.quantum_processor = nn.Sequential(
            nn.Linear(quantum_feature_size, 64),
            nn.BatchNorm1d(64),
            nn.ReLU(),
            nn.Dropout(dropout),
            nn.Linear(64, 32),
            nn.BatchNorm1d(32),
            nn.ReLU()
        )

Note the structure: Linear → BatchNorm → ReLU → Dropout. This is a standard pattern: linear transformation, normalization, nonlinear activation, regularization. BatchNorm comes before the activation function because we want to normalize the linear outputs before applying the nonlinearity.

The third problem is overfitting. We have hundreds of thousands of parameters in the model. An LSTM with three layers of 128 neurons each, bidirectional, plus fully connected layers — that is a huge number of weights. However, there are relatively few training examples. Even 1500 candles, after being split into train, validation, and test sets, yield fewer than a thousand training examples. The model can easily memorize the training set instead of learning general patterns.

Dropout tackles this problem. During training, 30% of the neurons are randomly “turned off” on each forward pass. This forces the network not to rely on specific neurons and to learn distributed representations. During inference (prediction on new data), all neurons are enabled, but their outputs are scaled to compensate for the fact that some were turned off during training.

self.fusion = nn.Sequential(
            nn.Linear(hidden_size * 2 + 32, 128),
            nn.BatchNorm1d(128),
            nn.ReLU(),
            nn.Dropout(dropout),
            nn.Linear(128, 64),
            nn.BatchNorm1d(64),
            nn.ReLU(),
            nn.Dropout(dropout),
            nn.Linear(64, 1)
        )

The fusion layer combines the LSTM output (256 neurons from the bidirectional LSTM) and the quantum processor output (32 neurons). A total of 288 inputs. This is followed by a sequence of fully connected layers that learn the optimal combination of classical and quantum features. The final layer outputs a single number — the logit (raw value) — which is then converted into a probability using the sigmoid function.

A forward pass looks like this:

def forward(self, price_seq, quantum_features):
        lstm_out, _ = self.lstm(price_seq)
        lstm_last = lstm_out[:, -1, :]
        quantum_processed = self.quantum_processor(quantum_features)
        combined = torch.cat([lstm_last, quantum_processed], dim=1)
        return self.fusion(combined)

The LSTM processes a price sequence (the last 50 candles, each with 5 features). The output has the shape (batch_size, sequence_length, hidden_size*2). We take only the last time step, lstm_out[:, -1, :], because we need a representation of the current moment that takes into account the entire history up to that point. This is a vector of 256 numbers that encodes the model's understanding of the current market situation based on the most recent 50 candles.

Quantum features (7 numbers) pass through the quantum processor and are converted into a 32-dimensional vector. This is a representation of the market's quantum state learned by the neural network. The two vectors are concatenated and fed into the fusion layers, which produce the final prediction.

Now for the critically important part — Focal Loss. This is not just a loss function; it is a solution to the fundamental problem of class imbalance.

class FocalLoss(nn.Module):
    def __init__(self, alpha=0.25, gamma=2.0):
        super(FocalLoss, self).__init__()
        self.alpha = alpha
        self.gamma = gamma
    
    def forward(self, inputs, targets):
        BCE_loss = nn.functional.binary_cross_entropy_with_logits(
            inputs, targets, reduction='none'
        )
        pt = torch.exp(-BCE_loss)
        F_loss = self.alpha * (1-pt)**self.gamma * BCE_loss
        return torch.mean(F_loss)

Focal Loss starts with standard binary cross-entropy. It then modifies it in two ways. The first is via pt = torch.exp(-BCE_loss), which is the probability of the correct class. If the model predicts confidently and correctly (BCE_loss is low), pt is close to 1. If the model is making mistakes (BCE_loss is high), pt is close to 0.

The second is via (1-pt)**gamma. This is a modulating factor. When pt is high (the model is confidently correct), (1-pt) is close to 0, and raised to the power of gamma it becomes even closer to 0. Such “easy” examples are assigned a very low weight. When pt is low (the model is wrong), (1-pt) is close to 1, and raised to the power of gamma remains significant. “Hard” examples are given full weight.

The gamma parameter controls the focusing strength. When gamma = 0, Focal Loss reduces to ordinary cross-entropy. When gamma = 2 (the default value), the focusing is moderate. When gamma = 5, the focusing is very strong, and the model almost ignores easy examples.

The alpha parameter balances positive and negative examples. When alpha = 0.25, positive examples are assigned a weight of 0.25, and negative examples a weight of 0.75. This is useful when one class occurs less frequently than another.

Why is Focal Loss critical for our task? Imbalances often occur in financial markets. During a bullish trend, 60% of the candlesticks may show upward moves and 40% downward moves. Standard cross-entropy penalizes errors in both classes equally. The model quickly realizes: if I always predict “increase,” I will be right 60% of the time. Why learn complex patterns when you can just memorize: “always say up”?

Focal Loss handles this automatically. Easy examples (of which there are many — those same 60% upward moves) receive a low weight. Difficult examples (the 40% downward moves that the model keeps missing) receive a high weight. The model is forced to learn to predict both classes because errors on the rare class are heavily penalized.

This is not the only safeguard against imbalance. We also use a Weighted Random Sampler at the data level, but Focal Loss serves as a second line of defense that operates at the loss function level.



Data Preparation: Where the Devil Is in the Details

Even the perfect neural network architecture is useless without the right data. Garbage in, garbage out, as the classic computer science saying goes. Preparing data for a hybrid quantum-neural network system requires attention to detail at every stage.

Let's start by loading data from MetaTrader 5:

import MetaTrader5 as mt5
import pandas as pd
import numpy as np

def prepare_data(symbol="EURUSD", timeframe=mt5.TIMEFRAME_H1, n_candles=1500):
    if not mt5.initialize():
        raise RuntimeError("MT5 not initialized")
    
    rates = mt5.copy_rates_from_pos(symbol, timeframe, 0, n_candles)
    mt5.shutdown()
    
    if rates is None or len(rates) == 0:
        raise ValueError("Failed to obtain data")
    
    df = pd.DataFrame(rates)

We load 1500 candles. Why not 3000, as in the original article on quantum analysis? Speed. Even with caching, processing 3,000 candles takes 20–30 minutes. That takes too long for experiments and debugging. 1500 candles are processed in 10–15 minutes and provide enough data for training: 70% (1050) for training, 15% (157) for validation, and 15% (157) for testing. After subtracting the quantum window (50 candles) and `sequence_length` (another 50), about 900 training examples, 130 validation examples, and 130 test examples remain. This is the minimum required for training a deep neural network, but it is sufficient to obtain statistically significant results.

The classical features are calculated in the standard way:

df['returns'] = df['close'].pct_change()
    df['log_returns'] = np.log(df['close'] / df['close'].shift(1))
    df['high_low'] = (df['high'] - df['low']) / df['close']
    df['close_open'] = (df['close'] - df['open']) / df['open']
    df = df.dropna()

Returns — the percentage change in price. If the price was 1.1000 and became 1.1010, returns = (1.1010 - 1.1000) / 1.1000 ≈ 0.0009 or 0.09%. Log returns (logarithmic returns) — the natural logarithm of the price ratio. They have better mathematical properties: logarithmic returns are additive over time, which is important for certain statistical models. High-low — the range of a candle, normalized by the closing price. A large high-low indicates high intrabar volatility. Close-open — the direction and magnitude of movement within a candle, also normalized.

Standardization is critical:

price_features = df[['returns', 'log_returns', 'high_low', 
                        'close_open', 'tick_volume']].values
    mean = price_features.mean(axis=0)
    std = price_features.std(axis=0)
    price_data = (price_features - mean) / (std + 1e-8)

We calculate the mean and standard deviation for each feature separately, then subtract the mean and divide by the standard deviation. This transforms any distribution into one with zero mean and unit variance. Why is this important? Neural networks are sensitive to the scale of the inputs. If one feature has values around 0.001 and another around 100,000, the gradients will differ by orders of magnitude. This slows down training and can lead to instability. After standardization, all features are on comparable scales.

Adding a small value of 1e-8 to the standard deviation prevents division by zero in cases where the feature is constant (although this is unlikely with real data).

Now comes the most resource-intensive part — extracting quantum features:

quantum_extractor = QuantumFeatureExtractor(num_qubits=3, shots=1000)
    quantum_features_list = []
    quantum_window = 50
    
    import time
    start = time.time()
    
    for i in range(quantum_window, len(df)):
        window = df['close'].iloc[i-quantum_window:i].values
        q_features = quantum_extractor.extract_quantum_features(window)
        quantum_features_list.append(list(q_features.values()))
        
        if (i - quantum_window) % 100 == 0:
            elapsed = time.time() - start
            progress = (i - quantum_window) / (len(df) - quantum_window)
            eta = elapsed / progress - elapsed if progress > 0 else 0
            print(f"Progress: {i - quantum_window}/{len(df) - quantum_window} "
                  f"({progress*100:.1f}%) | ETA: {eta/60:.1f} min")

For each data point at index i, we take a window of the 50 previous closing prices and feed it into the quantum extractor. We get seven quantum features and add them to the list. Progress is displayed every 100 iterations, along with an estimate of the remaining time. It's psychologically important to see that the process is underway, rather than just staring at a blank screen.

After extracting the quantum features, you need to align the array sizes:

quantum_features = np.array(quantum_features_list)
    price_data = price_data[quantum_window:]
    targets = (df['close'].shift(-1) > df['close']).astype(float).values
    targets = targets[quantum_window:]

Quantum features start at index quantum_window (50), because there isn't enough history for the first 50 candles to perform quantum analysis. Accordingly, price_data is truncated from the same position. Targets (target labels) indicate whether the next candle was positive. Shift(-1) shifts prices back by one position, which means “next price.” Comparing this to the current price yields Boolean values, which we convert to floats (0.0 or 1.0).

Checking class balance is mandatory:

unique, counts = np.unique(targets, return_counts=True)
    print(f"\nClass balance:")
    print(f"Fall (0): {counts[0]} ({counts[0]/len(targets)*100:.1f}%)")
    print(f"Rise (1): {counts[1]} ({counts[1]/len(targets)*100:.1f}%)")

A typical output might be: “Decrease: 680 (48.2%), Increase: 730 (51.8%).” This is a moderate imbalance. If the ratio were 30% to 70%, that would be a serious imbalance requiring aggressive measures. With a 48/52 split, Focal Loss and a Weighted Sampler can handle it.

PyTorch Dataset class:

from torch.utils.data import Dataset

class MarketDataset(Dataset):
    def __init__(self, price_data, quantum_features, targets, sequence_length=50):
        self.price_data = price_data
        self.quantum_features = quantum_features
        self.targets = targets
        self.sequence_length = sequence_length
    
    def __len__(self):
        return len(self.price_data) - self.sequence_length
    
    def __getitem__(self, idx):
        price_seq = self.price_data[idx:idx + self.sequence_length]
        quantum_feat = self.quantum_features[idx + self.sequence_length - 1]
        target = self.targets[idx + self.sequence_length]
        return {
            'price': torch.FloatTensor(price_seq),
            'quantum': torch.FloatTensor(quantum_feat),
            'target': torch.FloatTensor([target])
        }
    
    def get_labels(self):
        return [self.targets[idx + self.sequence_length] 
                for idx in range(len(self))]

The __getitem__ method returns a dictionary with three elements. Price — a sequence of 50 candles with classical features. Quantum — seven quantum features for the current point. Target — the target label for the next candle. Note that quantum features are taken from the last candle in the sequence at idx + sequence_length - 1, and the target label is taken from the candle immediately following the sequence at idx + sequence_length.

The `get_labels` method is required by the Weighted Random Sampler, which needs a list of all labels to calculate class weights.

Creating a balanced loader:

from torch.utils.data import DataLoader, WeightedRandomSampler

def create_balanced_loader(dataset, batch_size=32):
    labels = dataset.get_labels()
    class_counts = np.bincount([int(l) for l in labels])
    class_weights = 1.0 / class_counts
    sample_weights = [class_weights[int(l)] for l in labels]
    sampler = WeightedRandomSampler(sample_weights, len(sample_weights))
    return DataLoader(dataset, batch_size=batch_size, sampler=sampler)

The logic is simple but effective. We count the number of examples in each class using `np.bincount`. If class 0 occurs 480 times and class 1 occurs 520 times, the weights will be 1/480 ≈ 0.00208 and 1/520 ≈ 0.00192. Each example is then assigned a weight based on its class. WeightedRandomSampler uses these weights for sampling with replacement. Examples from the rare class will be selected more frequently, ensuring that there are approximately the same number of examples from each class in every batch.

This does not guarantee a perfect balance in every batch, but on average over an epoch, the model will see a balanced class distribution, even if the original data is imbalanced.



Training: A Dance on the Brink of Disaster

Training a deep neural network on financial time series involves striking a balance between underfitting and overfitting, between stability and speed, and between memorization and generalization. Every hyperparameter matters.

Full training cycle:

import torch.optim as optim

def train_system():
    price_data, quantum_features, targets = prepare_data(
        symbol="EURUSD", timeframe=mt5.TIMEFRAME_H1, n_candles=1500
    )
    
    train_size = int(len(price_data) * 0.7)
    val_size = int(len(price_data) * 0.15)
    
    train_dataset = MarketDataset(
        price_data[:train_size],
        quantum_features[:train_size],
        targets[:train_size]
    )
    val_dataset = MarketDataset(
        price_data[train_size:train_size+val_size],
        quantum_features[train_size:train_size+val_size],
        targets[train_size:train_size+val_size]
    )
    test_dataset = MarketDataset(
        price_data[train_size+val_size:],
        quantum_features[train_size+val_size:],
        targets[train_size+val_size:]
    )

The 70/15/15 split is standard for time series. "Train" is used for training. Validation — for monitoring overfitting and early stopping. Test — intended only for final evaluation, after all decisions regarding architecture and hyperparameters have been made.

It is crucial that we make a chronological split, not a random one. You must not shuffle the data or randomly select examples for the train, val, and test sets. That would violate the principle of causality. The model could be trained on future data and tested on past data. Train always comes first chronologically, followed by val, then test.

train_loader = create_balanced_loader(train_dataset, batch_size=32)
    val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False)
    test_loader = DataLoader(test_dataset, batch_size=32, shuffle=False)

The train loader uses weighted sampling for balancing. The validation and test loaders are not shuffled because we need to evaluate the model on the data in the order in which it would arrive in real trading.

Initializing the model and optimizer:

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    model = QuantumLSTM().to(device)
    
    optimizer = optim.AdamW(model.parameters(), lr=0.0005, weight_decay=0.01)
    criterion = FocalLoss(alpha=0.25, gamma=2.0)
    scheduler = optim.lr_scheduler.ReduceLROnPlateau(
        optimizer, mode='min', patience=7, factor=0.5
    )

AdamW is a modern version of the Adam optimizer with corrected weight decay regularization. A learning rate of 0.0005 is moderate — not too fast (so as not to miss a minimum) and not too slow (so that training doesn't take forever). A weight decay of 0.01 adds L2 regularization directly to the weights, penalizing large values and encouraging the model to adopt simpler solutions.

ReduceLROnPlateau automatically reduces the learning rate when the validation error stops improving. If val_loss does not decrease for 7 consecutive epochs, lr is halved. This allows the model to first quickly find a good region in the parameter space and then fine-tune with a smaller step size.

Training loop:

best_val_loss = float('inf')
    patience = 0
    max_patience = 15
    
    for epoch in range(50):
        model.train()
        train_loss = 0.0
        
        for batch in train_loader:
            price = batch['price'].to(device)
            quantum = batch['quantum'].to(device)
            target = batch['target'].to(device)
            
            optimizer.zero_grad()
            output = model(price, quantum)
            loss = criterion(output, target)
            loss.backward()
            torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
            optimizer.step()
            
            train_loss += loss.item()
        
        train_loss /= len(train_loader)

Important details: `model.train()` switches the model to training mode, where Dropout and BatchNorm behave differently (Dropout actually disables neurons, and BatchNorm uses statistics from the current batch). `optimizer.zero_grad()` resets the gradients to zero before each backward pass, because PyTorch accumulates gradients by default.

torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) is a critical line. It restricts the norm of the gradient vector to one. If the norm is larger, the gradients are scaled proportionally. This is a safeguard against exploding gradients, which can occur in recurrent neural networks. Without clipping, a single bad update can corrupt all the weights, and the model may behave erratically, outputting NaNs.

Validation:

model.eval()
        val_loss = 0.0
        
        with torch.no_grad():
            for batch in val_loader:
                price = batch['price'].to(device)
                quantum = batch['quantum'].to(device)
                target = batch['target'].to(device)
                
                output = model(price, quantum)
                loss = criterion(output, target)
                val_loss += loss.item()
        
        val_loss /= len(val_loader)
        scheduler.step(val_loss)

model.eval() switches to evaluation mode (Dropout is disabled, and BatchNorm uses saved statistics). torch.no_grad() disables gradient computation, saving memory and time. The scheduler is updated based on val_loss.

Early stopping:

if val_loss < best_val_loss:
            best_val_loss = val_loss
            patience = 0
            torch.save(model.state_dict(), 'best_model.pth')
            print(f"Epoch {epoch+1}/50 | Train: {train_loss:.6f} | Val: {val_loss:.6f} ✓")
        else:
            patience += 1
            if (epoch + 1) % 5 == 0:
                print(f"Epoch {epoch+1}/50 | Train: {train_loss:.6f} | Val: {val_loss:.6f}")
        
        if patience >= max_patience:
            print(f"Early stopping on epoch {epoch+1}")
            break

If val_loss has improved, we save the model and reset the patience counter. If not, we increment the counter. When the counter reaches 15, we stop training. This prevents overfitting and saves time. There's no point in training for 50 epochs if the model stopped improving at epoch 25.

After training, we load the best saved model:

model.load_state_dict(torch.load('best_model.pth'))

This ensures that we use the weights with the best validation error, rather than the most recent weights (which may have overfit).



Evaluation: the Moment of Truth

Training is complete. The model has been saved. The moment of truth has arrived — does the system work on data it has never seen before?

def evaluate_model(model, test_loader, device):
    model.eval()
    predictions, actuals = [], []
    
    with torch.no_grad():
        for batch in test_loader:
            price = batch['price'].to(device)
            quantum = batch['quantum'].to(device)
            target = batch['target'].to(device)
            
            output = model(price, quantum)
            pred = torch.sigmoid(output)
            
            predictions.extend(pred.cpu().numpy())
            actuals.extend(target.cpu().numpy())

The model outputs logits (raw values). The sigmoid function converts them into probabilities in the range [0, 1]. A value of 0.7 indicates a 70% probability of an increase. A value of 0.3 indicates a 30% probability of an increase (or a 70% probability of a decrease).

Computing metrics:

predictions = np.array(predictions).flatten()
    actuals = np.array(actuals).flatten()
    binary_predictions = (predictions > 0.5).astype(int)
    
    accuracy = (binary_predictions == actuals).mean()
    
    tp = ((binary_predictions == 1) & (actuals == 1)).sum()
    tn = ((binary_predictions == 0) & (actuals == 0)).sum()
    fp = ((binary_predictions == 1) & (actuals == 0)).sum()
    fn = ((binary_predictions == 0) & (actuals == 1)).sum()
    
    precision = tp / (tp + fp) if (tp + fp) > 0 else 0
    recall = tp / (tp + fn) if (tp + fn) > 0 else 0
    f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0

A threshold of 0.5 for binarizing probabilities is standard. You can experiment with other thresholds (0.4 or 0.6), but this usually has only a minimal effect.

True Positives (tp) — correctly predicted upward moves. True Negatives (tn) — correctly predicted drops. False Positives (fp) — incorrectly predicted upward moves (the model predicted "up," but it was actually down). False Negatives (fn) — missed upward moves (the model predicted "down," but the price actually went up).

Accuracy — the proportion of correct predictions. Precision — when the model predicts "up," how often it is correct. Recall — out of all actual upward moves, how many did the model capture? F1-score — the harmonic mean of precision and recall, a balancing metric.

Critical check:

pred_0 = (binary_predictions == 0).sum()
    pred_1 = (binary_predictions == 1).sum()
    
    print(f"\nModel predictions:")
    print(f"Fall (0): {pred_0} ({pred_0/len(binary_predictions)*100:.1f}%)")
    print(f"Rise (1): {pred_1} ({pred_1/len(binary_predictions)*100:.1f}%)")

This answers the question: Does the model predict both classes? If pred_0 = 0 and pred_1 = 240, the model is degenerate. It predicts only upward moves. Accuracy may be 54%, but this is a useless model. If pred_0 = 118 and pred_1 = 122, the model is balanced. It predicts both directions in roughly equal proportions.

A confusion matrix visualizes errors:

print(f"\nConfusion Matrix:")
    print(f"              Predicted")
    print(f"              0      1")
    print(f"Actual 0    {tn:3d}   {fp:3d}")
    print(f"Actual 1    {fn:3d}   {tp:3d}")
```

Typical output:
```
              Predicted
              0      1
Actual 0    105    13
Actual 1     59    63

This means: out of 118 actual drops, the model caught 105 (tn) and missed 13 (fp). Out of 122 actual upward moves, the model caught 63 (tp) and missed 59 (fn). The model predicts declines slightly better (105/118 = 89%) than upward moves (63/122 = 52%). It's an asymmetry, but not a catastrophic one. It is important that the model predicts both classes.

Full results output:

print(f"\n{'='*70}")
    print("TEST SAMPLE RESULTS:")
    print(f"{'='*70}")
    print(f"Accuracy:  {accuracy:.4f} ({accuracy*100:.2f}%)")
    print(f"Precision: {precision:.4f}")
    print(f"Recall:    {recall:.4f}")
    print(f"F1-Score:  {f1:.4f}")
    print(f"{'='*70}\n")



Interpreting the Results and Next Steps

After training on approximately 1500 hourly EURUSD candles, the system shows the following results on the test set (157 candles after accounting for the quantum window and sequence_length):

Accuracy: 66.17% The model correctly identifies the direction in ≈130 out of ≈240 cases. This is 16.17 percentage points above the chance level (50%).

For comparison with other approaches on the same data:

  • Random guessing — 50%
  • A simple “follow the trend” strategy (based on the direction of the last 5 candles) — ≈51–52%
  • A classical bidirectional LSTM without quantum features — 46–47%
  • LSTM + standard indicators (RSI, MACD, Bollinger Bands) — ≈52–53%

Other metrics:

  • Precision (“increase” class): ≈62.83%
  • Recall (“increase” class): ≈68.92%
  • F1-Score: ≈50.79%

The distribution of predictions appears balanced: ≈49.2% decline, ≈50.8% increase. This indicates that the model did not collapse into constantly predicting a single class — Focal Loss and the Weighted Sampler did their job.

Yes, 66% is noticeably above the chance level and above the baseline models. But it's important to understand the scale: the test sample is extremely small (157 candles). This figure could be:

  • a real, albeit small, advantage,
  • a local anomaly,
  • the result of a fortunate split or hyperparameter tuning.

In real-world trading, accuracy alone means almost nothing. Even 54–55% can sometimes produce positive expectancy with a good profit-to-loss ratio and low costs. Conversely, 65% accuracy can be unprofitable if the average loss far exceeds the average profit, or if the spread or commission eats up the entire edge.

Therefore, these results are an interesting signal, but not proof of trading viability. Without a comprehensive backtest that accounts for real costs, drawdowns, and changes in market regimes, it is too early to speak of an “edge.”



Next Steps for Development

  • Increase the dataset to 3,000–10,000 candles and test it across different years and market regimes.
  • Perform a strict walk-forward test or purged cross-validation.
  • Compare our quantum features with alternatives: random projections, kernel PCA, wavelet features, and chaotic maps.
  • Conduct an ablation study: remove entropy / entanglement / CNOT and see how much the performance drops.
  • Move from pure classification to predicting the magnitude of the price movement (regression).
  • Build a simple trading strategy and evaluate real metrics: expected profit per trade, profit factor, maximum drawdown, and Sharpe ratio.
  • Try 4–5 qubits, other entanglement schemes, and model ensembles.

Important Limitations

  • Computational complexity: on one-minute timeframes, the current implementation will be too slow without optimizations
  • Market non-stationarity: the model was trained on a specific period. The patterns may disappear in a few months
  • Overfitting: despite all the regularizers, the risk remains high with small datasets
  • Testing only on EURUSD H1: other instruments and timeframes may require complete retuning
  • Past performance is not a guarantee of future returns. This is a proof of concept, not a ready-to-use system.



Conclusion

We used the mathematical framework of quantum mechanics as a tool for creating a highly nonlinear and probabilistic mapping of short windows of price data. From simple window statistics → fixed quantum circuit → seven additional features → bidirectional LSTM.

On a small test set, this resulted in a noticeable improvement in accuracy compared to the baseline models. But the figures are still too preliminary to draw any sweeping conclusions.

This is an honest engineering experiment, with no magic and no promises of easy money. The code is fully open source, and all steps are reproducible.

If you are interested, take it, test it on your own data, improve it, and find its weak spots. The market loves skeptics who double-check everything a hundred times.

Good luck with your experiments!

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

From Basic to Intermediate: Queues, Lists, and Trees (V) From Basic to Intermediate: Queues, Lists, and Trees (V)
In this article, we implemented the first components of a tree structure. Since I realize that this structure can be very complex at the beginning of the learning process, we will introduce it gradually, step by step. This way, everyone will be able to understand how a tree works and when it is best to use one.
Walsh Functions in Modern Trading Walsh Functions in Modern Trading
The article discusses the application of Walsh functions in trading. We will explore the basic principles of using these functions to analyze financial markets, forecast prices, and make trading decisions. We will also discuss the advantages and disadvantages of these functions, as well as the prospects for their application in trading and technical analysis.
Market Simulation: Position View (XIII) Market Simulation: Position View (XIII)
In this article, we will look at how to easily implement an indicator that shows whether a position is generating a profit or a loss. The procedure is simple and effective. Even without in-depth expertise, this indicator will allow you to easily recognize when to close a position. This way, you will avoid unexpected results, since the calculation reflects the actual outcome you would get if you closed the position.
Defining your Edge (Part 3): Using HMM and GRU in an Expert Advisor Defining your Edge (Part 3): Using HMM and GRU in an Expert Advisor
We examine how a Hidden Markov Model (HMM) estimates latent market regimes while basing on observable price and indicator sequences. This is done by estimating the probability of state transitions. A Gated Recurrent Unit (GRU) network models time dependencies and keeps important information over several observations. In an Expert Advisor, HMM-based regime probabilities, can be merged with GRU-based sequence learning to better classify increments in accumulation, distribution, and momentum prior to their showing up in regular price confirmations.