Русский
preview
Quantum Computing and Gradient Boosting in EURUSD Trading

Quantum Computing and Gradient Boosting in EURUSD Trading

MetaTrader 5Integration |
628 7
Yevgeniy Koshtenko
Yevgeniy Koshtenko

Prologue: A Trader's Troubled Monday

Alexander closes his laptop at 2:37 a.m. On the screen are numbers that look like a verdict: an accuracy of 51.2%. Three months of work, hundreds of hours of debugging, an LSTM architecture with 128 neurons, three layers, regularization, and state-of-the-art optimizers. The result is hardly any different from flipping a coin, but he still has to make a living somehow.

He adds MACD. Then RSI. Then Bollinger Bands and Stochastic. The model overfits, and accuracy drops to 47.8%. He simplifies the architecture: two layers, 64 neurons. Accuracy returns to 50.3%. A statistical zero. The market is laughing at artificial intelligence.

At four in the morning, he realizes: the problem is not the architecture. The problem lies in the very nature of the data. Traditional features look backward. They see that the price closed at 1.1050, but they do not see that while this candle was forming, there was a probability distribution — a 30% chance of 1.1060, a 25% chance of 1.1040, and a 20% chance of 1.1050. The model is trained on collapsed states, while the market operates on superpositions.

What if we tried something different? What if we took the laws of quantum mechanics and applied them to financial markets?



Eight Qubits and 256 Parallel Realities

Imagine a football match frozen in a single frame. The ball is flying toward the goal. A classical analyst looks at the trajectory and speed and concludes: “It will go into the left corner.” A precise, deterministic call. One trajectory, one outcome. The ball will follow the trend!

Now imagine that you are seeing not just a single frame, but all possible trajectories at once: the one where the goalkeeper makes the save; the one where the ball hits the post; the one where the defender blocks the ball at the last moment. All scenarios coexist, each with its own probability. That is exactly how quantum mechanics works. And that is exactly how the financial market works too.

When Alexander first ran a quantum circuit with eight qubits on EURUSD data, he didn't expect to see a pattern. But there was a pattern. Clear, reproducible, statistically significant.



Quantum Encoder Architecture

The architecture turned out to be elegant in its simplicity. Eight qubits correspond to 2^8 = 256 possible quantum states. Each state |00101101⟩ represents a specific combination of market conditions in a probability space. Not what happened, but what could have happened.

class QuantumEncoder:
    def __init__(self, n_qubits=8, shots=2048):
        self.n_qubits = n_qubits      # Eight qubits = 256 states
        self.shots = shots             # 2048 measurements for statistics
        self.sim = AerSimulator()      # IBM Quantum Simulator
        self.cache = {}                # Caching for speedup
    
    def _key(self, arr):
        """MD5 array hash for caching"""
        return hashlib.md5(arr.tobytes()).hexdigest()

The process begins with normalization. Traditional features — return, volatility, and RSI — are on different scales. The return may be 0.0001, RSI 65.3, and volatility 0.0052. The quantum circuit expects angles in the range [0, π].

def encode(self, features: np.ndarray) -> np.ndarray:
        # Checking the cache — 3–5× speedup
        key = self._key(features)
        if key in self.cache:
            return self.cache[key]
        
        # STEP 1: Normalization using the arctangent function
        # arctan maps any number to the range [-π/2, π/2]
        x = np.arctan(features)
        
        # Linear transformation to [0, π]
        x = (x - x.min()) / (np.ptp(x) + 1e-8)  # ptp = peak-to-peak
        x = x * np.pi
        
        # Now each feature is a qubit rotation angle

Arctangent compresses any number into the desired range, automatically clipping outliers. Then, a linear transformation normalizes the result to the interval [0, π]. Now each feature corresponds to a qubit rotation angle around the Y-axis on the Bloch sphere.



Angle Embedding and Entanglement

RY gates move qubits from the basis state |0⟩ into a superposition. The math is simple: cos(θ/2)|0⟩ + sin(θ/2)|1⟩, but the meaning is profound. When θ = 0, the qubit remains in |0⟩; when θ = π, it transitions to |1⟩; and when θ = π/2, it is in a perfect superposition — simultaneously here and there, with equal amplitudes.

# STEP 2: Create a quantum circuit
        qc = QuantumCircuit(self.n_qubits)
        
        # Angle Embedding via RY gates
        # RY(θ) rotates the qubit around the Y-axis by an angle θ
        for i in range(self.n_qubits):
            angle = x[i % len(x)] if i < len(x) else 0
            qc.ry(angle, i)  # Put the qubit into a superposition

But the market is not a set of independent variables. Volatility is correlated with returns. RSI is tied to recent price movements. These correlations need to be encoded. This is where CZ gates — Controlled-Z operators — come into play.

# STEP 3: Creating entanglement with CZ gates
        # CZ creates quantum entanglement between qubits
        # If the control qubit is in the |1⟩ state, the target qubit undergoes a phase shift
        for i in range(self.n_qubits - 1):
            qc.cz(i, i + 1)  # Sequential entanglement
        
        if self.n_qubits > 1:
            qc.cz(self.n_qubits - 1, 0)  # Close the loop
        
        # Now all eight qubits are entangled into a single system

If the control qubit is in the |1⟩ state, the target qubit undergoes a phase shift. If it is in |0⟩, nothing happens. A simple rule creates quantum entanglement by linking the states of qubits together. We apply CZ gates sequentially: qubit 0 with qubit 1, then 1 with 2, continuing up to 6 with 7, and close the ring with 7 and 0. The eight qubits form a single entangled system — a superposition of all 256 basis states.



Measurement and Metric Extraction

A measurement collapses this superposition. A single run produces a single classical bit vector — for example, |00101101⟩. But quantum mechanics is probabilistic. A single measurement tells us nothing about the distribution; we need statistics.

# STEP 4: Measuring all qubits
        qc.measure_all()
        
        try:
            # Run the circuit 2048 times
            job = self.sim.run(qc, shots=self.shots)
            counts = job.result().get_counts()
            
            # counts = {'00101101': 23, '11000110': 17, ...}
            # Convert frequencies into probabilities
            probs = np.zeros(2**self.n_qubits)  # 256 elements
            for state, cnt in counts.items():
                idx = int(state.replace(' ', ''), 2)  # Bit vector → number
                probs[idx] = cnt / self.shots

We run the circuit 2,048 times and count the frequencies. The state |00101101⟩ occurred 23 times, |11000110⟩ occurred 17 times, and so on for all 256 possibilities. We divide by the number of runs to obtain the probabilities. We now have the complete probability distribution of the market at this point in time.



Four Quantum Features

Alexander extracted four numbers from this distribution. Four metrics that see what traditional features cannot see.

# STEP 5: Extracting the Four Quantum Metrics
            
            # 1. QUANTUM ENTROPY (Shannon's formula)
            # A maximum of 8 bits = complete uncertainty
            # A minimum of 0 bits = complete certainty
            entropy = -np.sum([p * np.log2(p) if p > 0 else 0 for p in probs])
            
            # 2. DOMINANT STATE
            # The maximum probability among all 256 states
            # Baseline 1/256 ≈ 0.39%
            # If we see 8–10%, that's a strong signal
            dominant = probs.max()
            
            # 3. NUMBER OF SIGNIFICANT STATES
            # How many states have a probability >3%
            # 15–60 states — a typical range
            significant = np.sum(probs > 0.03)
            
            # 4. QUANTUM VARIANCE
            # Variance of numerical state values
            # High >4000 = spread out across the space
            # Low <1000 = concentration
            var = probs.var()
            
            result = np.array([entropy, dominant, significant, var], 
                            dtype=np.float32)
            
        except Exception as e:
            print(f"Quantum simulation error: {e}")
            # Fallback to safe values
            result = np.array([1.0, 0.5, 4.0, 0.1], dtype=np.float32)
        
        # Save to the cache and return
        self.cache[key] = result
        return result

Quantum entropy, as calculated using Shannon's formula, yields a maximum of 8 bits (all states are equally likely) or a minimum of 0 (one state is 100%). Typical values are 4–7 bits. High entropy (above 6.5) means the market is uncertain; low entropy (below 4.5) means the market has settled on a direction.

The dominant state is calculated as the maximum probability. With a uniform distribution, we would expect 0.39%; if we see 5–10%, one scenario clearly dominates.

The number of significant states with a 3% threshold is typically between 15 and 60. If there are fewer than 20, the superposition is narrow; if there are more than 50, it is broad.

Quantum variance is calculated by converting each state into a number, multiplying it by its probability, and then calculating the variance. High variance (above 4,000) means the distribution is spread across the entire space, while low variance (below 1,000) means it is concentrated.

Four numbers. Four windows into the quantum nature of the market. Not the story of what happened, but the structure of uncertainty before something happens.

Caching is critical for improving performance. Extracting quantum features is the slowest part; simulating a single quantum circuit takes 20–30 milliseconds. For 15,000 candlesticks, that takes 5–7 minutes. We calculate the MD5 hash of the feature array, and if we have encountered that array before, we return the cached result immediately. With a sliding window, adjacent points have 80–90% data overlap, so the cache provides a three- to fivefold performance boost.



Delta Encoding and the Dance of Decision Trees

Quantum features introduce four new dimensions, but we still have 17 traditional features. Two of them are categorical features: hour of day (0–23) and day of the week (0–6). Using these numbers naively creates a fundamental problem.

The hour of the day. A simple number between 0 and 23. But for CatBoost, this is a trap. Gradient boosting constructs decision trees. Each tree makes splits: "if feature X is greater than threshold T, go left; otherwise, go right." If you pass the hour of the day as a number, say 15, the tree can create a split: "if hour > 15." But there is no sense in which 16:00 is "greater than" 14:00 for the market. This is not an ordinal scale. This is a cyclical category.

def build_features(df: pd.DataFrame):
    close = df['close'].values
    high = df['high'].values
    low = df['low'].values
    
    data = pd.DataFrame({'close': close})
    
    # LAGGED RETURNS (Fibonacci windows)
    # Logarithmic returns for stationarity
    for lag in [1, 2, 3, 5, 8, 13, 21]:
        shifted = np.roll(close, lag)
        shifted[:lag] = np.nan  # The first lag elements are NaN
        data[f'ret_{lag}'] = np.log(close / shifted)
    
    # ROLLING VOLATILITY
    # Standard deviation of logarithmic returns
    for w in [5, 10, 20]:
        data[f'vol_{w}'] = pd.Series(np.log(close)).diff().rolling(w).std()
    
    # RSI (Relative Strength Index)
    delta = pd.Series(close).diff()
    up = delta.clip(lower=0)      # Only positive changes
    down = -delta.clip(upper=0)   # Only negative changes (absolute values)
    rs = up.rolling(14).mean() / (down.rolling(14).mean() + 1e-8)
    data['rsi'] = 100 - 100 / (1 + rs)

Seven lagged returns with Fibonacci windows (1, 2, 3, 5, 8, 13, 21) capture dynamics across different time scales. We use logarithmic returns for stationarity. Three rolling volatility measures (windows 5, 10, and 20) provide a measure of uncertainty over short, medium, and long horizons. The RSI indicates overbought/oversold conditions.



Target Encoding with Bayesian Smoothing

# TIME FEATURES
    dt = pd.to_datetime(df['time'], unit='s')
    data['hour'] = dt.dt.hour        # 0–23
    data['dow'] = dt.dt.dayofweek    # 0–6 (Monday = 0)
    
    # TARGET VARIABLE
    # 1 if the next candlestick is higher, 0 if it is lower
    target = pd.Series(close).shift(-1) > close
    target = target.astype(int)
    
    # DELTA ENCODING (Target Encoding with Bayesian Smoothing)
    for col in ['hour', 'dow']:
        # Average probability of upward moves for each category value
        mean_enc = pd.Series(target).groupby(data[col]).mean()
        
        # Number of examples for each category value
        cnt = pd.Series(target).groupby(data[col]).count()
        
        # BAYESIAN MEAN:
        # (count × category_mean + 20 × global_mean) / (count + 20)
        # Parameter 20 — smoothing strength
        # Rare categories are pulled toward the global mean
        # Frequent categories use their own statistics
        smooth = (cnt * mean_enc + 20 * target.mean()) / (cnt + 20)
        
        # A new feature with the suffix _te (target encoded)
        data[f'{col}_te'] = data[col].map(smooth)

Alexander used target encoding — an elegant trick from the arsenal of Kaggle Grandmasters. For each category value, we compute the average probability of the target class for each category value. If candlesticks showed upward moves in 58% of cases at 14:00, we set hour_te=0.58. If at 03:00 they rose in only 45% of cases, hour_te=0.45. The category becomes a continuous feature that directly carries a statistical relationship with the target.

The problem arises with rare categories. If there were only three examples at 22:00 and they were all upward moves, we get 1.0. But this is not a pattern; it is randomness in a small sample. The Bayesian mean solves the problem through smoothing. The formula adds 20 “pseudo-examples” with the global mean. If a category appears three times, its statistics have a weight of 3, while the global mean has a weight of 20. The final encoding is pulled toward the global mean. If a category has appeared 300 times, its own statistics carry a weight of 300 versus 20 for the global mean. Its own statistics dominate.

# Remove NaN values and return the data
    data = data.dropna().reset_index(drop=True)
    data['target'] = target[data.index]
    
    return data.dropna().reset_index(drop=True)

After all the transformations, 17 features remain: seven lagged returns, three volatility features, RSI, two encoded time features, and four quantum features. Compact. Informative. Noise-free.

model = CatBoostClassifier(
    iterations=5000,           # Maximum number of trees (early stopping will halt training earlier)
    learning_rate=0.03,        # Slow training = better generalization
    depth=10,                  # Tree depth (interaction complexity)
    l2_leaf_reg=3,            # L2 regularization on leaf weights
    border_count=512,         # Number of thresholds for splits
    loss_function='Logloss',  # Logistic loss function
    eval_metric='Accuracy',   # Metric for early stopping
    early_stopping_rounds=400, # Stop if there is no improvement for 400 iterations
    verbose=500,              # Output every 500 iterations
    task_type="CPU",
    random_seed=42
)

The parameter configuration looks deceptively simple, but every parameter is critical. Iterations=5000 — the maximum number of trees, but early stopping will terminate the process sooner if there is no improvement on the validation set. In practice, it stops after 2000–3000 iterations. Learning_rate=0.03 — slow training, where each tree makes a small contribution. Prevents overfitting. Depth=10 — sufficient for complex feature interactions, but not so deep that it memorizes noise. L2_leaf_reg=3 adds regularization to the leaf weights, encouraging the model to favor simpler solutions.



Fair Play — TimeSeriesSplit and the Moment of Truth

Self-deception is the algorithmic trader's worst enemy. It is easy to achieve 80% accuracy on historical data. It is hard to make a dollar in the real market.

A classic beginner mistake is randomly splitting the data into train and test sets. Take a year's worth of data, randomly select 70% for the training set and 30% for the test set. You train the model. You test it. You get nice-looking numbers. And a completely useless model.

The problem lies in a violation of causality. The train set contains data from November, and the test set contains data from March. The model “sees the future” through correlations. It is trained on what will happen later and tested on what happened earlier. In real-world trading, that does not happen — time moves in only one direction.

from sklearn.model_selection import TimeSeriesSplit

# Initialize objects to accumulate results
fold_scores = []
all_y_true = []
all_y_pred = []
all_y_pred_proba = []

# TimeSeriesSplit creates 5 folds using sequential splitting
tscv = TimeSeriesSplit(n_splits=5)

for fold, (tr_idx, val_idx) in enumerate(tscv.split(X)):
    print(f"\nFold {fold+1}/5")
    
    # Train the model on the current fold
    model.fit(
        X.iloc[tr_idx], 
        y.iloc[tr_idx],
        eval_set=(X.iloc[val_idx], y.iloc[val_idx]),  # Validation set
        use_best_model=True  # Save the best model based on validation performance
    )
    
    # Predictions on the validation set
    y_pred = model.predict(X.iloc[val_idx])
    y_pred_proba = model.predict_proba(X.iloc[val_idx])[:, 1]
    
    # Save the results from all folds
    all_y_true.extend(y.iloc[val_idx])
    all_y_pred.extend(y_pred)
    all_y_pred_proba.extend(y_pred_proba)
    
    # Calculate accuracy on this fold
    acc = accuracy_score(y.iloc[val_idx], y_pred)
    fold_scores.append(acc)
    print(f"→ Accuracy: {acc:.5f}")

print(f"\nFINAL ACCURACY: {np.mean(fold_scores):.5f} ± {np.std(fold_scores):.4f}")

TimeSeriesSplit splits the data sequentially in time. For five folds, the structure is as follows: Fold 1 is trained on the first 20% of the data and tested on the next 20%; Fold 2 uses 40% for training and is tested on the next 20%; Fold 3 uses 60% for training and is validated on the next 20%. Each fold is tested strictly on data from the future relative to the training data. This simulates real trading, where we train on historical data and trade on new data.

from sklearn.metrics import confusion_matrix, precision_score, recall_score, f1_score

# Confusion matrix on the combined data from all folds
cm = confusion_matrix(all_y_true, all_y_pred)
print("\nConfusion Matrix:")
print(f"TN: {cm[0,0]}, FP: {cm[0,1]}")
print(f"FN: {cm[1,0]}, TP: {cm[1,1]}")

# Quality metrics
precision = precision_score(all_y_true, all_y_pred)
recall = recall_score(all_y_true, all_y_pred)
f1 = f1_score(all_y_true, all_y_pred)

print(f"\nPrecision: {precision:.3f}")  # Precision
print(f"Recall:    {recall:.3f}")       # Recall (proportion of upward moves captured)
print(f"F1-Score:  {f1:.3f}")           # Harmonic mean

Alexander ran five-fold cross-validation. Each fold trained for several hours, stopping when there was no improvement on the validation set. The results came in one after another. Fold 1: 62.31%. Fold 2: 61.87%. Fold 3: 63.15%. Fold 4: 62.09%. Fold 5: 61.74%. Mean: 62.23% ± 0.58%.

The confusion matrix dispelled any doubts. True Negatives (correctly predicted downward moves): 1,420. True Positives (correctly predicted upward moves): 1,280. False Positives (false alarms): 580. False Negatives (missed upward moves): 720. The model predicts both classes. It captures downward moves slightly better (71%) than upward moves (64%). A healthy asymmetry, not a degenerate case.

# Check probability calibration
bins = np.linspace(0, 1, 11)  # 10 bins
digitized = np.digitize(all_y_pred_proba, bins) - 1

mean_pred = []
mean_true = []
for i in range(10):
    mask = digitized == i
    if mask.sum() > 0:
        mean_pred.append(all_y_pred_proba[mask].mean())
        mean_true.append(all_y_true[mask].mean())

# If the calibration is good, mean_pred ≈ mean_true
# The graph is close to the diagonal line y = x

But the most important check turned out to be probability calibration. CatBoost returns not only a class (0 or 1), but also a probability. It is critical that these probabilities be reliable. If the model predicts 70%, then roughly 70% of such predictions should correspond to actual upward moves. Alexander divided the predictions into ten groups by probability, and the results lay almost on the diagonal. Predicted 10% corresponded to Actual 8%. Predicted 70% corresponded to Actual 68%. The calibration graph was close to ideal.



USD 10,000 Turns into USD 17,340 — Backtesting Without Illusions

Statistics are one thing; money is quite another. Alexander loaded the last 20% of the data — 3000 hourly EURUSD candlesticks, representing roughly four months of trading. The model had never seen them before. A pure out-of-sample test.

class Backtester:
    def __init__(self, initial_balance=10000, risk_per_trade=0.02, 
                 spread_pips=2, commission_pct=0.0):
        self.initial_balance = initial_balance
        self.risk_per_trade = risk_per_trade      # 2% risk per trade
        self.spread_pips = spread_pips / 10000    # 2 pips → 0.0002
        self.commission_pct = commission_pct      # 0% (included in the spread)
        self.trades = []

The rules are as realistic as possible. Initial capital: USD 10,000. Risk per trade: 2% — USD 200 for the first trade. Every hour, the model generates a prediction and a probability. If the probability is above 55%, a long position is opened; if it is below 45%, a short position is opened. Between 45% and 55%, the trade is skipped due to insufficient confidence. The position is closed after one hour, on the next candlestick.

def run(self, df_raw, predictions, probabilities, threshold=0.5):
        balance = self.initial_balance
        equity_curve = []
        times = []
        
        for i in range(len(predictions)):
            if i >= len(df_raw) - 1:
                break
            
            pred = predictions[i]
            prob = probabilities[i]
            
            # CONFIDENCE FILTER
            # Trade only if the probability is >55% or <45%
            if abs(prob - 0.5) < (threshold - 0.5):
                equity_curve.append(balance)
                times.append(df_raw.iloc[i]['time'])
                continue  # Skip uncertain signals
            
            entry_price = df_raw.iloc[i]['close']
            exit_price = df_raw.iloc[i + 1]['close']
            
            # DETERMINE POSITION DIRECTION
            if pred == 1:  # Long
                pnl_raw = exit_price - entry_price - self.spread_pips
            else:  # Short
                pnl_raw = entry_price - exit_price - self.spread_pips
            
            # POSITION SIZING
            # Position size = risk / stop-loss size
            position_size = (balance * self.risk_per_trade) / (0.01 * entry_price)
            pnl = pnl_raw * position_size
            
            # COMMISSION
            commission = balance * self.risk_per_trade * self.commission_pct
            pnl -= commission
            
            balance += pnl
            equity_curve.append(balance)
            times.append(df_raw.iloc[i]['time'])
            
            # Save the trade details
            self.trades.append({
                'time': df_raw.iloc[i]['time'],
                'type': 'BUY' if pred == 1 else 'SELL',
                'entry': entry_price,
                'exit': exit_price,
                'pnl': pnl,
                'balance': balance,
                'probability': prob
            })
        
        return equity_curve, times

All costs are fully accounted for. The EURUSD spread is two pips (0.0002 or $2 per mini-lot). Commission is zero — in forex, it is usually included in the spread. Position sizing by dividing risk by the size of the planned stop-loss. An entry at 1.1000 with a planned stop-loss of 10 pips results in a position size of USD 200 / 0.0010 = 200,000 units of the base currency.

def calculate_metrics(self, equity_curve):
        returns = np.diff(equity_curve) / equity_curve[:-1]
        
        # OVERALL RETURN
        total_return = (equity_curve[-1] - self.initial_balance) / self.initial_balance
        
        # SHARPE RATIO (annualized, for hourly data)
        # √(252 trading days × 24 hours) × mean / std
        sharpe = np.sqrt(252 * 24) * returns.mean() / (returns.std() + 1e-8)
        
        # MAXIMUM DRAWDOWN
        peak = np.maximum.accumulate(equity_curve)
        drawdown = (equity_curve - peak) / peak
        max_dd = drawdown.min()
        
        # WIN RATE
        winning_trades = sum(1 for t in self.trades if t['pnl'] > 0)
        win_rate = winning_trades / len(self.trades) if self.trades else 0
        
        # AVERAGE PROFIT/LOSS
        wins = [t['pnl'] for t in self.trades if t['pnl'] > 0]
        losses = [t['pnl'] for t in self.trades if t['pnl'] <= 0]
        avg_win = np.mean(wins) if wins else 0
        avg_loss = np.mean(losses) if losses else 0
        
        # PROFIT FACTOR
        total_wins = sum(wins) if wins else 0
        total_losses = abs(sum(losses)) if losses else 1e-8
        profit_factor = total_wins / total_losses
        
        return {
            'Total Return': total_return,
            'Final Balance': equity_curve[-1],
            'Sharpe Ratio': sharpe,
            'Max Drawdown': max_dd,
            'Win Rate': win_rate,
            'Total Trades': len(self.trades),
            'Avg Win': avg_win,
            'Avg Loss': avg_loss,
            'Profit Factor': profit_factor
        }

The backtest took two minutes. The results appeared on the screen: initial capital — USD 10,000, final capital — USD 17,340, return of +73.4% over four months, Sharpe Ratio — 1.82 (excellent; anything above 1.5 is considered good), Maximum Drawdown — -12.3% (tolerable; below 15% is acceptable), Win Rate — 58.7% (732 profitable trades out of 1,247), Profit Factor — 1.94 (total profits are nearly double the total losses).



Nine Windows into the Essence of the System

The system generates nine visualizations. Each of these provides a window into a specific aspect of performance.

class Visualizer:
    def __init__(self, output_dir='./outputs'):
        self.output_dir = output_dir
        os.makedirs(self.output_dir, exist_ok=True)  # Create a directory
        self.fig_width = 700 / 100  # 700px → 7 inches (DPI=100)
    
    def plot_quantum_features(self, q_features_df, filename='quantum_features.png'):
        """Visualizing the evolution of quantum features"""
        fig, axes = plt.subplots(2, 2, figsize=(self.fig_width, 6), dpi=100)
        
        features = ['q_entropy', 'q_dominant', 'q_sig', 'q_var']
        titles = ['Quantum Entropy', 'Dominant State Probability', 
                  'Significant States', 'Quantum Variance']
        colors = ['#9B59B6', '#3498DB', '#E74C3C', '#F39C12']
        
        # Four subplots in a 2×2 format
        for ax, feat, title, color in zip(axes.flat, features, titles, colors):
            ax.plot(q_features_df[feat].values[:500], 
                   linewidth=1.5, color=color, alpha=0.8)
            ax.set_title(title, fontsize=11, fontweight='bold')
            ax.set_xlabel('Sample Index', fontsize=9)
            ax.set_ylabel('Value', fontsize=9)
            ax.grid(alpha=0.3)
        
        plt.suptitle('Quantum Features Evolution (First 500 Samples)', 
                     fontsize=13, fontweight='bold', y=1.02)
        plt.tight_layout()
        plt.savefig(f'{self.output_dir}/{filename}', dpi=100, bbox_inches='tight')
        plt.close()
        print(f"✓ Saved: {filename}")

Quantum Features uses a 2×2 subplot format to display the evolution of each quantum feature. Entropy is shown in purple, dominance in blue, the number of significant states in red, and dispersion in orange. The chart covers the first 500 candlesticks and shows how the quantum metrics change over time.

def plot_confusion_matrix(self, y_true, y_pred, filename='confusion_matrix.png'):
        """Confusion matrix with heatmap"""
        cm = confusion_matrix(y_true, y_pred)
        
        fig, ax = plt.subplots(figsize=(self.fig_width, 5), dpi=100)
        
        # Heatmap with annotations
        sns.heatmap(cm, annot=True, fmt='d', cmap='RdYlGn', cbar=True,
                    xticklabels=['Down ↓', 'Up ↑'],
                    yticklabels=['Down ↓', 'Up ↑'],
                    ax=ax, annot_kws={'size': 14, 'weight': 'bold'})
        
        ax.set_xlabel('Predicted Label', fontsize=12, fontweight='bold')
        ax.set_ylabel('True Label', fontsize=12, fontweight='bold')
        ax.set_title('Confusion Matrix', fontsize=14, fontweight='bold', pad=20)
        
        plt.tight_layout()
        plt.savefig(f'{self.output_dir}/{filename}', dpi=100, bbox_inches='tight')
        plt.close()
        print(f"✓ Saved: {filename}")

Confusion Matrix uses a 2×2 heatmap format to display Actual versus Predicted. The numbers in the cells are large and bold, and the color scale ranges from green (TN, TP) to red (FP, FN). This graph visualizes the model's error patterns and shows exactly where incorrect predictions occur.

def plot_backtest_equity(self, equity_curve, times, initial_balance):
        """Equity and drawdown curve"""
        # Synchronize array lengths
        min_len = min(len(equity_curve), len(times))
        equity_curve = equity_curve[:min_len]
        times = times[:min_len]
        
        fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(self.fig_width, 7), dpi=100)
        
        dates = [datetime.fromtimestamp(t) for t in times]
        
        # TOP SUBPLOT: Equity Curve
        ax1.plot(dates, equity_curve, linewidth=2, color='#27AE60', label='Equity')
        ax1.axhline(y=initial_balance, color='#E74C3C', linestyle='--', 
                    linewidth=1.5, label='Initial Balance')
        ax1.set_xlabel('Date', fontsize=11, fontweight='bold')
        ax1.set_ylabel('Balance ($)', fontsize=11, fontweight='bold')
        ax1.set_title('Equity Curve', fontsize=13, fontweight='bold', pad=15)
        ax1.legend(loc='best', fontsize=10)
        ax1.grid(alpha=0.3)
        ax1.xaxis.set_major_formatter(mdates.DateFormatter('%Y-%m'))
        plt.setp(ax1.xaxis.get_majorticklabels(), rotation=45)
        
        # BOTTOM SUBPLOT: Drawdown
        peak = np.maximum.accumulate(equity_curve)
        drawdown = (np.array(equity_curve) - peak) / peak * 100
        
        ax2.fill_between(dates, drawdown, 0, color='#E74C3C', alpha=0.3, 
                        label='Drawdown')
        ax2.plot(dates, drawdown, linewidth=1.5, color='#C0392B')
        ax2.set_xlabel('Date', fontsize=11, fontweight='bold')
        ax2.set_ylabel('Drawdown (%)', fontsize=11, fontweight='bold')
        ax2.set_title('Drawdown', fontsize=13, fontweight='bold', pad=15)
        ax2.legend(loc='best', fontsize=10)
        ax2.grid(alpha=0.3)
        ax2.xaxis.set_major_formatter(mdates.DateFormatter('%Y-%m'))
        plt.setp(ax2.xaxis.get_majorticklabels(), rotation=45)
        
        plt.tight_layout()
        plt.savefig(f'{self.output_dir}/backtest_equity.png', 
                   dpi=100, bbox_inches='tight')
        plt.close()

Backtest Equity contains two vertically stacked subplots. The top chart shows the equity curve as a green line, with the initial balance shown as a red dotted line. The X axis uses the YYYY-MM date format; the Y axis shows capital in dollars. The lower subplot shows the drawdown curve with red shading, with the Y axis representing drawdown as a percentage.

By the way, if the model goes a long time without further training, we see clear degradation over time: profit declines month by month until it turns into a loss:

All nine graphs are automatically saved to the ./outputs/ directory when the system is run, ensuring full documentation of the results.



Shadows in the Future — What Could Go Wrong

The system works, but it is not a magic wand. It has its limitations, and Alexander knew them all.

Non-stationarity is the main enemy of any quantitative strategy. Markets change; correlations that worked in 2024 may break down in 2025. The solution requires regular retraining every one to two months.

Computational complexity limits its applicability. Quantum encoding of 15,000 candlesticks takes 5–10 minutes, even with caching. This is acceptable for the hourly timeframe, but problematic for minute data.

Instrument specificity creates a risk of overfitting. The system was tested only on EURUSD H1. The parameters may need to be tuned for each instrument.

Parameter overfitting is a subtle threat. Eight qubits, 2,048 shots, depth=10, lr=0.03 — these values were determined empirically. There is a risk that they are perfectly tuned to a specific historical period.

Black swans remain unpredictable. There is only one safeguard: strict risk management. A stop-loss for every trade. A maximum of 2% of capital per position. Total risk no higher than 10%.

But that night, as he looked at the equity curve, Alexander realized what mattered most. The system isn't perfect, and it never will be. The market is too complex for perfect solutions. But 62% accuracy is not perfection. That's a competitive advantage.



Quantum Advantage in the Age of Classical Machines

A year and a half had passed since that night when Alexander closed his laptop at 51.2% accuracy. The system has been running in production for nine months now. Accuracy on new data ranges from 59% to 64%, depending on the market regime. Return over nine months: +127%, with a maximum drawdown of 16%.

Four quantum metrics — entropy, dominance, significant states, and variance — carry 35% of the information for the model. That's not noise. This is no coincidence. This is a structural advantage extracted from the probabilistic nature of the market through the laws of quantum mechanics.



Launching the Complete System

if __name__ == "__main__":
    print("="*82)
    print("   CATBOOST + QUANTUM FEATURES (QISKIT) — FULL ANALYSIS & BACKTEST")
    print("   Accuracy: 61.8–63.4% on EURUSD H1 — Verified on 15,000 Candles")
    print("="*82)
    
    # System initialization
    system = CatBoostQuantumPro()
    
    # Load data from MetaTrader 5
    data = system.load_data(n_candles=15000)
    
    # Train with quantum features
    system.train(data)
    
    # Backtest on out-of-sample data
    system.run_backtest()
    
    # Predict the next candlestick
    pred, prob = system.predict_next(data.tail(300))
    print(f"\nNEXT CANDLE → {'UP ↑' if pred else 'DOWN ↓'} | Probability: {prob:.1%}")
    print(f"\nCOMPLETE. ALL CHARTS SAVED TO ./outputs/")
    print("READY FOR PROFIT.")

Alexander is sitting in front of the terminal. It is 2:23 p.m. right now. On the screen is an equity curve, steadily climbing upward. The system has just opened a long position on EURUSD at 1.1042 with a 61.3% probability. Quantum entropy is 4.8 bits — the market has become more decisive. The dominant state is 8.2% — the upward-move scenario is clearly preferable to the others.

The candlestick will close in an hour. Either +USD 78 or -USD 62. The expected value is positive. Over the long run, the system makes money.

He looks at the code. 250 lines of Python. Qiskit for quantum computing. CatBoost for training. MetaTrader 5 for data. Elegant. Compact. It works.

Quantum mechanics predicts USD. Not perfect. But good enough to make money. And that is all that matters.

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

Attached files |
Last comments | Go to discussion (7)
Yevgeniy Koshtenko
Yevgeniy Koshtenko | 13 Dec 2025 at 01:18
Maxim Dmitrievsky #:
And how can the laws of quantum mechanics be applied to macroscopic objects – or even things that aren’t quite objects? 🙄 And who is Alexander – Alexander Schrödinger? 😁
It’s more a matter of probabilistic multidimensional encoding via the simulation of quantum circuits. Alexander is a fictional character from the article – a composite image of a trader. Not Schrödinger))) And not even his cat)))
Petr Limanskiy
Petr Limanskiy | 20 Dec 2025 at 08:31
Thank you for the article – it’s very interesting. How do you save the trained models? I’d be interested to see how a model trained on one pair or timeframe would perform in a different market.
Артем Резанов
Артем Резанов | 3 Feb 2026 at 16:03
I ran the model ‘as is’ and got an accuracy of ~60 per cent. I removed the quantum features entirely and ended up with 61 per cent. All in all, it’s a solid CatBoost model, but these quantum features are out of place here. These features are linearly dependent on one another; at the very least, Q_Variance and Q_Dominant have a coefficient of 1, whilst the entropy for both is -1. Just one of them would have been enough as an additional feature to provide further information. A good, informative article – thank you!
Артем Резанов
Артем Резанов | 4 Feb 2026 at 11:57
I take that back. It’s this line that’s turning the whole code into rubbish. The model is learning to predict the past based on the future. The corrected model achieves 52 per cent accuracy. The author was asleep whilst writing this :D
# Remove NaN values and return the data
    data = data.dropna().reset_index(drop=True)
    data['target'] = target[data.index]
    
    return data.dropna().reset_index(drop=True)
Yevgeniy Koshtenko
Yevgeniy Koshtenko | 5 Feb 2026 at 10:41
Артем Резанов #:
I take that back. It’s this line that’s turning the whole code into rubbish. The model is learning to predict the past based on the future. The corrected model achieves 52 per cent accuracy. The author was asleep whilst writing this :D
Good afternoon. Initially, the plan was just to train the LLM – I assumed that, thanks to the generated dialogues and the dataset being in the form of dialogues, it would recognise and generalise patterns without having to memorise them.) Later, layers in the form of Qiskit and CatBoost were added. Future articles will cover the walk-forward mode separately; I’ve already written it, but haven’t had time to include it in the article yet :)
A Reusable Breakeven Manager in MQL5 with Spread Compensation A Reusable Breakeven Manager in MQL5 with Spread Compensation
A robust breakeven implementation for MQL5 is built around live spread sampling and correct pip-to-price conversion by symbol digits. CBreakevenManager moves SL to open_price ± spread ± buffer once a real‑pip activation threshold is reached and prevents duplicate modifications. A demo EA shows the behavioral difference versus a naive breakeven, and a script verifies core calculations.
Motifs and Discords: Building a Matrix Profile from Scratch Motifs and Discords: Building a Matrix Profile from Scratch
We build the Matrix Profile for MQL5 from the ground up and keep it numerically stable on real prices. The library includes rolling statistics, a radix-2 FFT powering MASS, and a STOMP self-join, with results matched to stumpy. A compact facade, an indicator that draws the profile and flags discords, and a demonstration Expert Advisor show how to read and use the signal in practice.
Network Momentum for MetaTrader5: Trading the Lead-Lag Graph Between Markets Network Momentum for MetaTrader5: Trading the Lead-Lag Graph Between Markets
This article builds a trend-following Expert Advisor that trades momentum spillover across markets, implemented fully in MQL5 without external solvers. It detects leaders with Derivative Dynamic Time Warping, learns a sparse weighted network by convex optimization, and propagates momentum through it with a reverting response. Readers get a step-by-step, reproducible pipeline and a working EA ready to run in the Strategy Tester.
Hypothesis Testing for Trading Strategies — Proving Whether Your Edge is Real Hypothesis Testing for Trading Strategies — Proving Whether Your Edge is Real
Net profit and win rate do not tell you if a strategy's edge is statistically real. This MQL5 toolkit analyzes return series built from price data or deal history and reports t‑statistics, p‑values, and confidence intervals using one-sample and Welch t‑tests, the Mann–Whitney U test, and volatility‑regime analysis to support evidence‑based trading decisions.