Quantum Computing and Gradient Boosting in EURUSD Trading
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
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.
A Reusable Breakeven Manager in MQL5 with Spread Compensation
Motifs and Discords: Building a Matrix Profile from Scratch
Network Momentum for MetaTrader5: Trading the Lead-Lag Graph Between Markets
Hypothesis Testing for Trading Strategies — Proving Whether Your Edge is Real
- Free trading apps
- Over 8,000 signals for copying
- Economic news for exploring financial markets
You agree to website policy and terms of use
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? 😁
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