Combining 3D Bars, Quantum Computing, and Machine Learning into a Unified Trading System
In previous articles, we explored the use of quantum computing to extract nonlinear correlations from market data, as well as the integration of language models with the CatBoost model. The forecast accuracy was 62.4% in cross-validation, which yielded a return of +27.39% over the one-month backtesting period on a USD 140 micro account.
However, the analysis showed that the system overlooks critically important information — the multidimensional structure of the interplay between price, time, and volume. Classic indicators operate on market projections onto two-dimensional charts, losing the broader multidimensional view of market activity. This article describes the full integration of the 3D-bar module into a quantum-enhanced trading system.
Architecture of the Integrated System
The system consists of four interconnected modules. The first module receives data from MetaTrader 5 for eight currency pairs on the M15 timeframe. These data are simultaneously fed into three parallel processors: the 3D-bar module, the Qiskit-based quantum encoder, and the technical indicator calculation unit.
# System configuration MODEL_NAME = "koshtenco/quantum-trader-fusion-3d" BASE_MODEL = "llama3.2:3b" SYMBOLS = ["EURUSD", "GBPUSD", "USDCHF", "USDCAD", "AUDUSD", "NZDUSD", "EURGBP", "AUDCHF"] TIMEFRAME = mt5.TIMEFRAME_M15 LOOKBACK = 400 # Quantum parameters N_QUBITS = 8 N_SHOTS = 2048 # 3D-bar parameters MIN_SPREAD_MULTIPLIER = 45 VOLUME_BRICK = 500 USE_3D_BARS = True
The 3D-bar module transforms non-stationary OHLCV data into stationary four-dimensional features. The quantum encoder uses 8 qubits to create 256 quantum states and extract nonlinear correlations. The technical indicators module calculates 33 classical features, including RSI, MACD, ATR, and others.
All features are combined and fed into the CatBoost model, which processes 52+ features simultaneously. Gradient boosting is trained to predict the direction of price movement 24 hours ahead. The CatBoost output is optionally processed by the Llama 3.2 3B language model, which adds contextual interpretation to the forecasts.
The Bars3D Class: Solving the Problem of Non-Stationarity
The main challenge in working with financial time series is their non-stationarity. EURUSD is trading at 1.0850 today; it could be at 1.0920 tomorrow and 1.1500 a year from now. Absolute price values are useless for machine learning because the model is trained on specific numbers that will never recur in the future.
class Bars3D: """ Class for creating stationary 4D features (3D bars) Implementation based on the article about multidimensional bars """ def __init__(self, min_spread_multiplier: int = 45, volume_brick: int = 500): self.min_spread_multiplier = min_spread_multiplier self.volume_brick = volume_brick self.scaler = MinMaxScaler(feature_range=(3, 9))
The normalization to the range from 3 to 9 is not coincidental and is related to the harmonics of the numbers 3, 6, and 9, which were used in Gann's theory and Nikola Tesla's research. Empirically, this range produces more stationary time series compared to standard normalization to the range from zero to one.
The create_3d_features method takes a DataFrame containing OHLCV data and returns an enriched DataFrame with stationary features:
def create_3d_features(self, df: pd.DataFrame, symbol_info=None) -> pd.DataFrame: """Create stationary 4D features from ordinary OHLCV data""" if len(df) < 21: log.warning("Insufficient data for 3D bars") return df d = df.copy() # Temporal dimension (cyclical features) if isinstance(d.index, pd.DatetimeIndex): d['time_sin'] = np.sin(2 * np.pi * d.index.hour / 24) d['time_cos'] = np.cos(2 * np.pi * d.index.hour / 24) # Price dimension (returns and acceleration) d['typical_price'] = (d['high'] + d['low'] + d['close']) / 3 d['price_return'] = d['typical_price'].pct_change() d['price_acceleration'] = d['price_return'].diff() # Volume dimension d['volume_change'] = d['tick_volume'].pct_change() d['volume_acceleration'] = d['volume_change'].diff() # Volatility dimension d['volatility'] = d['price_return'].rolling(20).std() d['volatility_change'] = d['volatility'].pct_change()
The first dimension represents the temporal structure through cyclical features. The hour of the day is encoded not as a linear number from 0 to 23, but using a pair of sine and cosine functions. The sine of the hour is calculated as sin(2π × hour / 24); the cosine is calculated analogously. This representation makes 23:00 and 00:00 mathematically close, unlike the naive approach, where 23 and 0 are as far apart as possible.
The second dimension describes price movement through returns and acceleration. Return is the percentage change in the typical price between bars. Price acceleration is the difference between the current and previous returns — that is, the second derivative of price with respect to time.
The third dimension deals with volume information. Volume change is calculated as the percentage change in tick volume between bars. Volume acceleration is the difference between the current and previous change in volume.
The fourth dimension captures volatility. Volatility is calculated as the standard deviation of returns over a 20-bar sliding window. Volatility change is the percentage change in volatility between bars.
For each bar starting from the 20th, a sliding window of 21 points is created:
# Creating normalized features in a sliding window bar3d_features = [] for idx in range(20, len(d)): window = d.iloc[idx-20:idx+1] features = { 'bar3d_price_return': float(window['price_return'].iloc[-1]), 'bar3d_price_accel': float(window['price_acceleration'].iloc[-1]), 'bar3d_volume_change': float(window['volume_change'].iloc[-1]), 'bar3d_volatility_change': float(window['volatility_change'].iloc[-1]), 'bar3d_volume_accel': float(window['volume_acceleration'].iloc[-1]), 'bar3d_time_sin': float(d.iloc[idx]['time_sin']), 'bar3d_time_cos': float(d.iloc[idx]['time_cos']), 'bar3d_price_velocity': float(window['price_acceleration'].mean()), 'bar3d_volume_intensity': float(window['volume_change'].mean()), 'bar3d_price_change_mean': float(window['price_return'].mean()), } bar3d_features.append(features)All features are combined into a DataFrame and normalized with MinMaxScaler to the range 3 to 9. Normalization is applied only to non-zero rows; missing values are filled using backfill:
# Normalization to the range 3–9 cols_to_scale = [col for col in bar3d_df.columns if col.startswith('bar3d_')] if cols_to_scale: result[cols_to_scale] = result[cols_to_scale].bfill().fillna(0) mask = result[cols_to_scale].abs().sum(axis=1) > 0 if mask.sum() > 0: result.loc[mask, cols_to_scale] = self.scaler.fit_transform( result.loc[mask, cols_to_scale] )
An analysis of more than 400,000 EURUSD bars covering the period from 2022 to 2024 revealed an interesting pattern. When both the 70th percentile of price volatility and the 70th percentile of volume volatility are exceeded simultaneously, there is an increased likelihood of a price reversal within the next few bars.
# Additional metrics result['bar3d_price_volatility'] = result['bar3d_price_change_mean'].rolling(10).std() result['bar3d_volume_volatility'] = result['bar3d_volume_change'].rolling(10).std() # Yellow cluster detector (reversal predictor) result['bar3d_yellow_cluster'] = ( (result['bar3d_price_volatility'] > result['bar3d_price_volatility'].quantile(0.7)) & (result['bar3d_volume_volatility'] > result['bar3d_volume_volatility'].quantile(0.7)) ).astype(float) # Probability of a reversal based on yellow clusters result['bar3d_reversal_prob'] = result['bar3d_yellow_cluster'].rolling(7, center=True).mean()
The detector is implemented using a logical condition. The 70th percentile is computed for the price-volatility measure derived from the second-order price feature, across the entire available time series. The 70th percentile of volume volatility is calculated in the same way. For each bar, the system checks whether the current price volatility exceeds its percentile and whether the current volume volatility exceeds its percentile.
The probability of a reversal is calculated as the sliding average of the yellow cluster over a centered 7-bar window. Centering means that the window looks at the three bars before the current bar, the current bar, and the three bars after it. This yields the local density of yellow clusters around the current point.
The physical meaning of the yellow cluster is as follows: the price is moving with abnormally high volatility relative to its historical distribution, while volume indicates instability in the order flow. The combination of these two factors creates a state of maximum uncertainty. Most traders find themselves in positions that turn out to be wrong. Smart money is starting to reverse positions against the crowd.
The direction and strength of the trend are calculated as follows:
# Trend direction result['bar3d_direction'] = np.sign(result['bar3d_price_return']) # Trend counter trend_count = [] count = 1 prev_dir = 0 for direction in result['bar3d_direction']: if pd.isna(direction): trend_count.append(0) continue if direction == prev_dir: count += 1 else: count = 1 trend_count.append(count) prev_dir = direction result['bar3d_trend_count'] = trend_count result['bar3d_trend_strength'] = result['bar3d_trend_count'] * result['bar3d_direction']
A Qiskit-Based Quantum Encoder
The QuantumEncoder class implements quantum feature encoding. The constructor takes the number of qubits and the number of measurements. Eight qubits create a space consisting of 2^8, or 256, possible basis states:
class QuantumEncoder: """Quantum encoder based on Qiskit""" def __init__(self, n_qubits: int = 8, n_shots: int = 2048): self.n_qubits = n_qubits self.n_shots = n_shots self.simulator = AerSimulator()
The encode_and_measure method takes an array of features and returns a dictionary containing four quantum metrics. The first step is to normalize the features and map them to rotation angles:
def encode_and_measure(self, features: np.ndarray) -> Dict[str, float]: """Code features into the quantum circuit""" # Normalization to the interval [0, π] normalized = (features - features.min()) / (features.max() - features.min() + 1e-8) angles = normalized * np.pi # Building a quantum circuit qc = QuantumCircuit(self.n_qubits, self.n_qubits) # RY rotations for feature encoding for i in range(min(len(angles), self.n_qubits)): qc.ry(angles[i], i) # Create entanglement using CZ gates (ring topology) for i in range(self.n_qubits - 1): qc.cz(i, i + 1) qc.cz(self.n_qubits - 1, 0) # Close the ring # Measurement qc.measure(range(self.n_qubits), range(self.n_qubits))
For each qubit, an RY rotation is applied with the corresponding angle. The RY gate rotates the qubit around the Y-axis of the Bloch sphere by a specified angle. Mathematically, this transforms the qubit from the basis state |0⟩ into the superposition cos(θ/2)|0⟩ + sin(θ/2)|1⟩.
The third step creates quantum entanglement between the qubits. A Controlled-Z gate is applied between each pair of adjacent qubits. Sequentially applying CZ gates between qubits creates a chain of correlations. Additionally, a CZ gate is applied between the last qubit, 7, and the first qubit, 0, closing the chain into a ring.
The circuit is executed on the simulator 2048 times. A probability array of length 256 is generated from the counts dictionary:
# Run the simulation job = self.simulator.run(qc, shots=self.n_shots) result = job.result() counts = result.get_counts() # Conversion to a probability array total_shots = sum(counts.values()) probabilities = np.array([ counts.get(format(i, f'0{self.n_qubits}b'), 0) / total_shots for i in range(2**self.n_qubits) ]) # Extract quantum features quantum_entropy = entropy(probabilities + 1e-10, base=2) dominant_state_prob = np.max(probabilities) significant_states = np.sum(probabilities > 0.03) quantum_variance = np.var(probabilities) return { 'quantum_entropy': quantum_entropy, 'dominant_state_prob': dominant_state_prob, 'significant_states': significant_states, 'quantum_variance': quantum_variance }
Four quantum features are derived from the probability distribution. Quantum entropy, according to Shannon's formula, is calculated as the negative sum over all states of the product of the probability and the base-two logarithm of the probability. Entropy is measured in bits and ranges from zero to eight.
High entropy — above 6.5 — indicates a market in a state of uncertainty, while low entropy — below 4.5 — indicates a market with a clear direction. The probability of the dominant state is calculated as the maximum value among all 256 probabilities. The number of significant states counts how many states have a probability above the 3% threshold. Quantum variance is the ordinary variance of an array of probabilities.
Technical Indicators: 33 Classic Features
The calculate_features function takes a DataFrame containing OHLCV data, an optional Bars3D instance, and symbol information:
def calculate_features(df: pd.DataFrame, bars_3d: Bars3D = None, symbol_info=None) -> pd.DataFrame: """Calculate technical indicators + 3D bars""" d = df.copy() d["close_prev"] = d["close"].shift(1) # ATR tr = pd.concat([ d["high"] - d["low"], (d["high"] - d["close_prev"]).abs(), (d["low"] - d["close_prev"]).abs(), ], axis=1).max(axis=1) d["ATR"] = tr.rolling(14).mean() # RSI delta = d["close"].diff() up = delta.clip(lower=0).rolling(14).mean() down = (-delta.clip(upper=0)).rolling(14).mean() rs = up / down.replace(0, np.nan) d["RSI"] = 100 - (100 / (1 + rs)) # MACD ema12 = d["close"].ewm(span=12, adjust=False).mean() ema26 = d["close"].ewm(span=26, adjust=False).mean() d["MACD"] = ema12 - ema26 d["MACD_signal"] = d["MACD"].ewm(span=9, adjust=False).mean() # Bollinger Bands d["BB_middle"] = d["close"].rolling(20).mean() bb_std = d["close"].rolling(20).std() d["BB_upper"] = d["BB_middle"] + 2 * bb_std d["BB_lower"] = d["BB_middle"] - 2 * bb_std d["BB_position"] = (d["close"] - d["BB_lower"]) / (d["BB_upper"] - d["BB_lower"]) # Stochastic low_14 = d["low"].rolling(14).min() high_14 = d["high"].rolling(14).max() d["Stoch_K"] = 100 * (d["close"] - low_14) / (high_14 - low_14) d["Stoch_D"] = d["Stoch_K"].rolling(3).mean() # EMA d["EMA_50"] = d["close"].ewm(span=50, adjust=False).mean() d["EMA_200"] = d["close"].ewm(span=200, adjust=False).mean() # Volumes and Returns d["vol_ratio"] = d["tick_volume"] / d["tick_volume"].rolling(20).mean() d["price_change_1"] = d["close"].pct_change(1) d["price_change_5"] = d["close"].pct_change(5) d["price_change_21"] = d["close"].pct_change(21) d["volatility_20"] = d["price_change_1"].rolling(20).std() # Integration of 3D bars if USE_3D_BARS and bars_3d is not None: d = bars_3d.create_3d_features(d, symbol_info) return d.dropna()
Average True Range is calculated as a moving average of the true range over a 14-bar window. The true range captures volatility while accounting for gaps between bars. The RSI is calculated using the formula: 100 minus 100 divided by (1 plus RS), where RS is the ratio of the average rise to the average decline.
MACD is the difference between two exponential moving averages with periods of 12 and 26 bars. Bollinger Bands are plotted around a moving average of the price, plus or minus two standard deviations. The Stochastic Oscillator is calculated using the low and high prices over a 14-bar period.
Training CatBoost on Combined Features
The train_catboost_model function takes a dictionary of DataFrames by symbol, an instance of a quantum encoder, and an instance of Bars3D:
def train_catboost_model(data_dict: Dict[str, pd.DataFrame], quantum_encoder: QuantumEncoder, bars_3d: Bars3D = None) -> CatBoostClassifier: """Train CatBoost on data with quantum features + 3D bars""" all_features = [] all_targets = [] for symbol, df in data_dict.items(): symbol_info = mt5.symbol_info(symbol) df_features = calculate_features(df, bars_3d, symbol_info) for idx in range(LOOKBACK, len(df_features) - PREDICTION_HORIZON): row = df_features.iloc[idx] future_row = df_features.iloc[idx + PREDICTION_HORIZON] # Target variable: UP (1) if the price is higher in 24 hours target = 1 if future_row['close'] > row['close'] else 0 # Quantum encoding feature_vector = np.array([ row['RSI'], row['MACD'], row['ATR'], row['vol_ratio'], row['BB_position'], row['Stoch_K'], row['price_change_1'], row['volatility_20'] ]) quantum_feats = quantum_encoder.encode_and_measure(feature_vector) # Combine all features features = { 'RSI': row['RSI'], 'MACD': row['MACD'], 'ATR': row['ATR'], 'vol_ratio': row['vol_ratio'], 'BB_position': row['BB_position'], 'Stoch_K': row['Stoch_K'], 'Stoch_D': row['Stoch_D'], 'EMA_50': row['EMA_50'], 'EMA_200': row['EMA_200'], 'price_change_1': row['price_change_1'], 'price_change_5': row['price_change_5'], 'price_change_21': row['price_change_21'], 'volatility_20': row['volatility_20'], 'quantum_entropy': quantum_feats['quantum_entropy'], 'dominant_state_prob': quantum_feats['dominant_state_prob'], 'significant_states': quantum_feats['significant_states'], 'quantum_variance': quantum_feats['quantum_variance'], 'symbol': symbol } # Add 3D bars if available if USE_3D_BARS and 'bar3d_price_return' in row: features.update({ 'bar3d_yellow_cluster': row.get('bar3d_yellow_cluster', 0), 'bar3d_reversal_prob': row.get('bar3d_reversal_prob', 0), 'bar3d_trend_strength': row.get('bar3d_trend_strength', 0), 'bar3d_price_volatility': row.get('bar3d_price_volatility', 0), 'bar3d_volume_volatility': row.get('bar3d_volume_volatility', 0), }) all_features.append(features) all_targets.append(target)
For each position, the current bar and the future bar — located PREDICTION_HORIZON positions ahead — are retrieved. The target variable is set to one if the closing price of the future bar is higher than the current bar's closing price; otherwise, it is set to zero. A feature vector for quantum encoding is created from eight key technical indicators.
A feature dictionary is created for the current bar. All 33 technical indicators are included, four quantum features are added, and the symbol name is added as a categorical feature. If the USE_3D_BARS flag is set, five key features from the 3D bars are added.
Training the model with TimeSeriesSplit cross-validation:
X = pd.DataFrame(all_features) y = np.array(all_targets) X = pd.get_dummies(X, columns=['symbol'], prefix='sym') model = CatBoostClassifier( iterations=3000, learning_rate=0.03, depth=8, loss_function='Logloss', eval_metric='Accuracy', random_seed=42, verbose=500 ) from sklearn.model_selection import TimeSeriesSplit tscv = TimeSeriesSplit(n_splits=3) accuracies = [] for fold_idx, (train_idx, val_idx) in enumerate(tscv.split(X)): X_train, X_val = X.iloc[train_idx], X.iloc[val_idx] y_train, y_val = y[train_idx], y[val_idx] model.fit(X_train, y_train, eval_set=(X_val, y_val), verbose=False) accuracy = model.score(X_val, y_val) accuracies.append(accuracy) print(f"Fold {fold_idx + 1} Accuracy: {accuracy*100:.2f}%") print(f"Average accuracy: {np.mean(accuracies)*100:.2f}% ± {np.std(accuracies)*100:.2f}%")
TimeSeriesSplit splits the data chronologically. With three folds, the first fold is trained on the first 33% of the data and validated on the next 33%. The second fold is trained on the first 67% and validated on the last 33%. The third fold is trained on all the data except the last 33% and validated on that portion.
Feature importance analysis shows the contribution of the 3D bars:
# Train the final model on all data model.fit(X, y, verbose=500) model.save_model("models/catboost_quantum_3d.cbm") # Feature importance analysis feature_importance = model.get_feature_importance() feature_names = X.columns importance_df = pd.DataFrame({ 'feature': feature_names, 'importance': feature_importance }).sort_values('importance', ascending=False) print("\nTOP-10 IMPORTANT FEATURES:") print(importance_df.head(10)) # Check whether 3D bars are among the top features if USE_3D_BARS: bar3d_features = importance_df[importance_df['feature'].str.startswith('bar3d_')] print(f"\n3D BARS IN TOP ({len(bar3d_features)} features):") print(bar3d_features.head(10))
The training results show an average accuracy of 65.8% with a standard deviation of 0.5%. This is 3.4 percentage points higher than the previous version without 3D bars. The top 10 features by importance include bar3d_yellow_cluster in first place with a feature importance of 18.7%, quantum_entropy in second with 16.2%, and bar3d_reversal_prob in third with 12.4%.
Backtesting: from USD 140 to USD 193
The backtest function evaluates the trained model on historical data from the past 30 days:
def backtest(catboost_model, use_llm=False): """Backtest with CatBoost + Quantum + 3D""" end = datetime.now().replace(second=0, microsecond=0) start = end - timedelta(days=BACKTEST_DAYS) # Load data data = {} for sym in SYMBOLS: rates = mt5.copy_rates_range(sym, TIMEFRAME, start, end) if rates is None or len(rates) == 0: continue df = pd.DataFrame(rates) df["time"] = pd.to_datetime(df["time"], unit="s") df.set_index("time", inplace=True) if len(df) > LOOKBACK + PREDICTION_HORIZON: data[sym] = df balance = INITIAL_BALANCE trades = [] quantum_encoder = QuantumEncoder(N_QUBITS, N_SHOTS) bars_3d = Bars3D(MIN_SPREAD_MULTIPLIER, VOLUME_BRICK) # Analysis points every 24 hours main_symbol = list(data.keys())[0] main_data = data[main_symbol] total_bars = len(main_data) analysis_points = list(range(LOOKBACK, total_bars - PREDICTION_HORIZON, PREDICTION_HORIZON))For each analysis point, the system loads historical data up to the current moment, calculates all features, including 3D bars, performs quantum encoding, and obtains a forecast from CatBoost:
for point_idx, current_idx in enumerate(analysis_points): current_time = main_data.index[current_idx] for sym in SYMBOLS: historical_data = data[sym].iloc[:current_idx + 1].copy() symbol_info = mt5.symbol_info(sym) df_with_features = calculate_features(historical_data, bars_3d, symbol_info) row = df_with_features.iloc[-1] # Quantum encoding feature_vector = np.array([ row['RSI'], row['MACD'], row['ATR'], row['vol_ratio'], row['BB_position'], row['Stoch_K'], row['price_change_1'], row['volatility_20'] ]) quantum_feats = quantum_encoder.encode_and_measure(feature_vector) # Preparing features for CatBoost X_features = { 'RSI': row['RSI'], 'MACD': row['MACD'], 'ATR': row['ATR'], # ... all 33 technical indicators 'quantum_entropy': quantum_feats['quantum_entropy'], 'dominant_state_prob': quantum_feats['dominant_state_prob'], 'significant_states': quantum_feats['significant_states'], 'quantum_variance': quantum_feats['quantum_variance'], } # Add 3D features if 'bar3d_yellow_cluster' in row: X_features.update({ 'bar3d_yellow_cluster': row.get('bar3d_yellow_cluster', 0), 'bar3d_reversal_prob': row.get('bar3d_reversal_prob', 0), 'bar3d_trend_strength': row.get('bar3d_trend_strength', 0), 'bar3d_price_volatility': row.get('bar3d_price_volatility', 0), 'bar3d_volume_volatility': row.get('bar3d_volume_volatility', 0), }) # CatBoost forecast X_df = pd.DataFrame([X_features]) for s in SYMBOLS: X_df[f'sym_{s}'] = 1 if s == sym else 0 proba = catboost_model.predict_proba(X_df)[0] catboost_direction = "UP" if proba[1] > 0.5 else "DOWN" catboost_confidence = max(proba) * 100 # Check for a yellow cluster if row.get('bar3d_yellow_cluster', 0) > 0.5: print(f" YELLOW CLUSTER!")The system checks for the presence of a yellow cluster and displays a warning. If the final confidence level exceeds the minimum threshold, a virtual trade is opened, taking all costs into account:
if final_confidence < MIN_PROB: continue # Calculate the result after 24 hours exit_idx = current_idx + PREDICTION_HORIZON exit_row = data[sym].iloc[exit_idx] # Account for the spread entry_price = row['close'] + SPREAD_PIPS * point if final_direction == "UP" else row['close'] exit_price = exit_row['close'] if final_direction == "UP" else exit_row['close'] + SPREAD_PIPS * point # Price movement in points price_move_pips = (exit_price - entry_price) / point if final_direction == "UP" else (entry_price - exit_price) / point # Position sizing based on ATR risk_amount = balance * RISK_PER_TRADE atr_pips = row['ATR'] / point stop_loss_pips = max(20, atr_pips * 2) lot_size = risk_amount / (stop_loss_pips * point * contract_size) lot_size = max(0.01, min(lot_size, 10.0)) # Profit including swap and slippage profit_usd = price_move_pips * point * contract_size * lot_size profit_usd -= swap_cost * (lot_size / 0.01) profit_usd -= SLIPPAGE * point * contract_size * lot_size balance += profit_usd
After processing all analysis points, the final statistics are calculated. Total number of trades, number of profitable trades, win rate, average profit and average loss, profit factor, maximum drawdown, Sharpe ratio.

Conclusion
The 3D-bar module improved the trading system's performance: accuracy +3.4 percentage points, win rate +3.85%, and return +10.66%. This underscores the value of multidimensional market analysis.
The yellow cluster detector identifies zones of elevated volatility. With coverage of approximately 40% of reversals, the signal exhibits high specificity and is useful for risk management.
Three of the five key features were derived from 3D bars. The bar3d_yellow_cluster feature is the most important (18.7%), ahead of quantum entropy (16.2%).
The system is implemented in a single Python file (1,691 lines). MetaTrader5, Qiskit, CatBoost, Ollama, NumPy, pandas, and scikit-learn are used. Training: 2–3 hours on the CPU. Forecast generation: ~3 seconds for 8 currency pairs.
Limitations: constructing 3D bars takes 5–10 minutes per 15,000 candles; the parameters are tuned for EURUSD M15 and require adaptation for other markets; market non-stationarity requires retraining every 1–2 months.
Future development: multi-timeframe analysis, use of real quantum processors, expansion to other assets, dynamic parameter tuning, model ensembles.
Translated from Russian by MetaQuotes Ltd.
Original article: https://www.mql5.com/ru/articles/20634
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.
Neural Networks in Trading: Disentangling Structured Components (Conclusion)
Decoding Market Intent: Reading Structure, Liquidity, and Price Behavior
Partial Information Decomposition: When Two Indicators Together Say More Than Either Alone
Building Your Personal Expert Advisor (Part 2): Risk Management and Dynamic Lot Sizing
- Free trading apps
- Over 8,000 signals for copying
- Economic news for exploring financial markets
You agree to website policy and terms of use
bar3d_yellow_cluster is in last place. What’s gone wrong?
An article has been published entitled ‘Combining 3D bars, quantum computing and machine learning into a single trading system’:
Author: Yevgeniy Koshtenko
By trialling different parameters for the CatBoost model and spending many hours on it, the best result we managed to achieve was
================================================================================
Average accuracy: 55.66% ± 4.34%
Training the final model on all the data...
✓ Model saved: models/catboost_quantum_3d.cbm
TOP 10 MOST IMPORTANT FEATURES:
feature importance
bar3d_trend_strength 30.591319
EMA_200 22.086104
EMA_50 14.959278
MACD 4.816617
volatility_20 4.555638
ATR 3.050433
bar3d_volume_volatility 2.690818
bar3d_price_volatility 2.354294
vol_ratio 2.308871
price_change_21 2.308647
TOP 3D BARS (5 characteristics):
feature importance
bar3d_trend_strength 30.591319
bar3d_volume_volatility 2.690818
bar3d_price_volatility 2.354294
bar3d_reversal_prob 0.735604
bar3d_yellow_cluster 0.215698
I don’t understand how bar3d_yellow_cluster ended up being the most important one
An analysis of the code provided has revealed that, in the current version , the training data and backtest data are not clearly distinguished.
This leads to data leakage , creating a situation where the backtesting results appear significantly better than the actual results.
I will explain the reason for this in detail by analysing the logic of the code.
1. Problem Analysis
A. Training Stage (Mode 1)
When Mode 1 is selected in the `main()` function, `load_mt5_data(180)` is called.
if choice == "1": data = load_mt5_data(180) # All data for the last 180 days is loaded # ... model = train_catboost_model(data, quantum_encoder, bars_3d)
Next, if we look inside the function, we can see that whilst cross-validation is performed by `train_catboost_model`, retraining is ultimately carried out using the entire dataset .
In other words, the model is trained using all the data up to ‘today’.
B. Backtesting stage (Mode 4)
The `backtest()` function performs testing over a period set to `BACKTEST_DAYS = 30` (the last 30 days).
end = datetime.now().replace(second=0,microsecond=0) start = end - timedelta(days=BACKTEST_DAYS) # The last 30 days
2. Conclusion: Cases of data leaks
Training data: [ 180 days ago] to [today]
Test data: [ Today – 30 days] to [Today]
The period to be tested (the last 30 days) is already included in the training data. This is equivalent to the model taking the test whilst having already ‘seen’ the correct answers, so the backtest win rate is unrealistically high.
3. Solution (Code Modification Guide)
To ensure accurate backtesting, the test period must be excluded from the training process .
Solution 1: Exclude the most recent data from the training function (recommended).
You must trim the data by the length of the back-test period either within the `train_catboost_model` function or during the data loading stage.
Solution 2: Separate the data from the core logic.
The simplest method is to set different time periods when loading data in the `main()` function.
Mode 1 (Training): load_mt5_data (start_days=210, end_days=30) (e.g. data from 210 days ago to 30 days ago)
Mode 4 (Test): backtest (days=30) (e.g. data from 30 days ago to today)
To summarise, the current code contains a prediction bias , meaning the backtest results cannot be trusted. It is essential to split the time period, retrain the model and then retest it before actual use.
Hello, yes, without delving into or examining the code, it looks very tempting. BUT, having read the previous commenter’s post – I took a look inside.
Indeed, the backtest is run on data that CatBoost has already seen.
Just for the sake of interest, I disabled this line in the code:
print("\nTraining the final model ON ALL THE DATA...")
# model.fit(X, y, verbose=500)
and voilà, on the 30-day backtest, mode 4, there isn’t a single trade (
CatBoost: barely over 50%
...ah, and happiness was so close..)
You’ve put in a huge amount of work... and it’s a disappointment
Just for the sake of interest, I disabled this line of code:
print("\nTraining the final model ON ALL THE DATA...")
# model.fit(X, y, verbose=500)
and voilà, on a 30-day backtest, mode 4, there isn’t a single trade (
CatBoost: barely over 50 per cent
...ah, and happiness was so close..)
You’ve put in a huge amount of work... and it’s disappointing