Русский
preview
Combining 3D Bars, Quantum Computing, and Machine Learning into a Unified Trading System

Combining 3D Bars, Quantum Computing, and Machine Learning into a Unified Trading System

MetaTrader 5Trading systems |
185 5
Yevgeniy Koshtenko
Yevgeniy Koshtenko

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

Last comments | Go to discussion (5)
Aliaksandr Kazunka
Aliaksandr Kazunka | 23 Dec 2025 at 11:42


bar3d_yellow_cluster is in last place. What’s gone wrong?

Aliaksandr Kazunka
Aliaksandr Kazunka | 25 Dec 2025 at 10:48

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

Anderson
Anderson | 6 Jan 2026 at 03:15

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.

Python

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 .

Python
                       # Inside `train_catboost_model`
    print("\nTraining the final model on the full dataset...")
    model.fit(X, y,verbose=500) # Here, `X` is the full dataset covering the last 180 days

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).

Python

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.

Python
                   # Suggested fix: change within the `train_catboost_model` function
def train_catboost_model(data_dict, quantum_encoder,bars_3d=None):
    # ... (omitted) ...
    
   # [Correction] Do not create X directly from all the data; it must be split by date.
    # Alternatively, simply exclude data from the last BACKTEST_DAYS before training.
    
    cutoff_index = len(df_features) - (BACKTEST_DAYS * 96) # Approximately 96 bars per day for M15
    
   # Use only data up to the cutoff point for training
    train_features = df_features.iloc[:cutoff_index] 
    
    # ... then use train_features for training ...

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.

djgagarin
djgagarin | 8 Feb 2026 at 11:11

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

Evgeny Belyaev
Evgeny Belyaev | 8 Feb 2026 at 15:16
djgagarin CatBoost has already seen.

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

Well, this chap churns out two articles a week. The code was most likely generated by AI.
Neural Networks in Trading: Disentangling Structured Components (Conclusion) Neural Networks in Trading: Disentangling Structured Components (Conclusion)
The article provides a detailed explanation of the SCNN architecture and one way to implement it using MQL5. We will show how time series decomposition can be combined with neural network methods and attention mechanisms.
Decoding Market Intent: Reading Structure, Liquidity, and Price Behavior Decoding Market Intent: Reading Structure, Liquidity, and Price Behavior
We implement a five-stage MQL5 pipeline that quantifies market structure, liquidity interaction, and price behavior on four timeframes, then resolves them into a 0–100 Market Intent Score. Decision states (WAIT/WATCH/ACTION) are driven by explicit weights plus hard gates. The analytical core feeds a concise dashboard and, when AutoTrade is on, an execution layer with entry zones, invalidation and liquidity‑based targets.
Partial Information Decomposition: When Two Indicators Together Say More Than Either Alone Partial Information Decomposition: When Two Indicators Together Say More Than Either Alone
We introduce a Partial Information Decomposition library for MQL5 that decomposes two sources about a target into four atoms: unique to each, shared, and synergy. The implementation uses quantile binning, tabulated logarithms, and a maximum-entropy fit (for I_ccs), and it pairs results with a block-permutation null because atoms sit above zero on finite samples. Use it to screen indicator pairs and judge significance, including family-wise correction.
Building Your Personal Expert Advisor (Part 2): Risk Management and Dynamic Lot Sizing Building Your Personal Expert Advisor (Part 2): Risk Management and Dynamic Lot Sizing
This part implements risk-based position sizing for the EA. Lot size is derived from account balance, a chosen risk percent, and ATR-based stop distance, then confined and rounded to the broker's volume rules and minimum stop levels. An optional drawdown-aware layer reduces risk during equity declines. Readers get a reproducible sizing function that keeps per-trade risk consistent and orders acceptable to the server.