Русский
preview
Fast Integration of a Large Language Model with MetaTrader 5 (Part II): Fine-Tuning on Real Data, Backtesting, and Live Trading by the Model

Fast Integration of a Large Language Model with MetaTrader 5 (Part II): Fine-Tuning on Real Data, Backtesting, and Live Trading by the Model

MetaTrader 5Integration |
423 2
Yevgeniy Koshtenko
Yevgeniy Koshtenko

The Problem with Base LLMs in Trading

After deploying the language model described in the first part of the article, the system processed technical indicators — RSI, MACD, and volume analysis — correctly, and the model generated BUY or SELL trading signals. However, during a week of testing on a demo account, a significant problem became apparent.

Let's look at a specific example. The model registered a BUY signal for the EURUSD pair at an RSI value of 32, which technically corresponds to the oversold zone. After entering the position, the price continued to fall by another 200 pips, and it was not until three days later that it reversed and began to rise. The stop-loss was triggered, and the account was down 3%. The next day, a similar situation occurred on GBPUSD: with the RSI at 28, the model generated a BUY signal, but the price dropped another 300 pips, resulting in additional losses of 3%.

The problem lies not in the accuracy of the calculations for the indicators, but in the lack of practical experience. A base language model functions like a novice trader who has learned the theoretical rule “RSI below 30 is a buy signal” but lacks knowledge of how a specific currency pair reacts to oversold conditions under various market conditions. For example, the model does not take into account that, during the Asian session, EURUSD may continue to fall — despite low RSI readings — if there is a strong daily downtrend.

The base LLM understands the theoretical foundations of technical analysis but lacks empirical data on the behavior of specific instruments. Specifically, the model does not know that, when the RSI is at 25, EURUSD statistically falls by another 40 pips on average before reversing, GBPUSD may decline by 150 pips in a similar situation, and a MACD divergence on the H4 chart for USDCHF results in a successful reversal 70% of the time, whereas for USDCAD this figure is only 40%.

To solve this problem, we need a model trained on real historical statistics for specific currency pairs — one that understands their behavior not from textbooks, but from the analysis of thousands of real market situations.

Solution: Fine-Tuning on Historical Data

Fine-tuning is the process of further training a pre-trained model on a specialized dataset. If we draw an analogy with trading practice, it is similar to a situation where a finance graduate joins a prop firm and, during the first month, trades under the guidance of an experienced senior trader. The mentor shows him a thousand real trades and explains in detail why each one worked or did not work. After a month of this practice, the graduate becomes a more experienced trader who has not only a theoretical foundation but also a practical understanding of how trading instruments actually behave.

Fine-tuning applies a similar approach to a language model. The process is divided into three sequential stages: generating a training dataset from historical MetaTrader 5 data, training the model using the Ollama framework, and verifying the results in a proper backtest without look-ahead bias. Each stage addresses a specific technical challenge and requires careful attention to implementation details.


Generating a Balanced Dataset

An analysis of the EURUSD chart for the past 6 months shows that the price has risen from 1.0500 to 1.1200, representing a 700-pip increase. If we create a training dataset by sequentially taking all examples from this period, the resulting sample will be significantly imbalanced: approximately 70% of the examples will be labeled “UP” (price increase) and only 30% will be labeled “DOWN” (price decrease).

A model trained on such an imbalanced dataset optimizes the loss function simply by memorizing that the correct answer is “UP” in most cases. This will produce an apparent accuracy of 70% on the training data, but it will lead to disastrous results in live trading once market conditions change and a correction or downtrend begins.

The solution to this problem is class balancing in the dataset. You need to download 6 months of historical quote data, identify all points where the price rose over the next 24 hours, and all points where the price fell over the next 24 hours. Next, 500 examples of price increases and 500 examples of price decreases are randomly selected from these sets. The result is a balanced dataset of 1,000 examples with a 50/50 class ratio.

Why the 24-Hour Prediction Horizon Was Chosen

A 24-hour prediction horizon (96 15-minute bars) was chosen based on several considerations. First, this is a sufficient time interval for significant market movements to emerge, which can be captured by technical indicators. Short-term horizons of 1 to 4 hours contain too much market noise and too many random fluctuations, which reduces predictability. Second, the 24-hour period allows us to account for the impact of different trading sessions (Asian, European, and U.S.), which is important for currency pairs. Third, from a practical standpoint, this is a convenient interval for an automated trading system that analyzes the market once a day.

Important note: this is not the only possible choice. Different prediction horizons may be optimal for different trading strategies (for example, 4–6 hours for intraday trading or 48–72 hours for swing trading). Your choice of prediction horizon should align with your trading philosophy and risk profile.

Why These Specific Currency Pairs Were Chosen

The current implementation uses four major currency pairs: EURUSD, GBPUSD, USDCHF, and USDCAD. This choice is based on several factors. These currency pairs are characterized by high liquidity and relatively tight spreads, both of which are critical for algorithmic trading. They exhibit different patterns of behavior: EURUSD and GBPUSD often show similar dynamics due to the correlation between EUR and GBP, while USDCHF often moves in the opposite direction (negative correlation with EURUSD), and USDCAD has its own specific behavior related to oil prices.

Critical note: combining all pairs into a single model can lead to the averaging of patterns and a decrease in accuracy. A better approach might be to train separate specialized models for each pair or to group similar pairs together (for example, EURUSD and GBPUSD into one model, and USDCHF and USDCAD into another). This requires significant computational resources, but it can significantly improve the results.

Why 1000 Examples Are Enough

A training set of 1,000 examples represents a compromise between training quality and computational cost. Fine-tuning a pretrained model (which understands the general principles of technical analysis) requires less data than training a model from scratch. A set of 1,000 examples is the minimum amount that allows the model to identify stable patterns in the behavior of the selected currency pairs.

Important warning: this is a fairly small dataset by machine learning standards. Larger datasets (5,000–10,000 examples) can yield significantly better results, especially if the model will be used under various market conditions. The small size of the dataset limits the model's ability to generalize its knowledge to new situations.


Implementing Dataset Generation

The script loads 180 days of historical data, iterates through each time point sequentially, determines the outcome after 24 hours, and generates an equal number of examples of price increases and decreases for each currency pair. The process takes 5–10 minutes, depending on the connection speed to the broker's server.

def generate_real_dataset_from_mt5(num_samples: int = 1000) -> list:
    if not mt5.initialize():
        print("MT5 not connected!")
        return []
    
    dataset = []
    up_count = 0
    down_count = 0
    target_up = num_samples // 2
    target_down = num_samples // 2
    
    end = datetime.now()
    start = end - timedelta(days=180)
    
    for symbol in ["EURUSD", "GBPUSD", "USDCHF", "USDCAD"]:
        rates = mt5.copy_rates_range(symbol, mt5.TIMEFRAME_M15, start, end)
        if rates is None:
            continue
            
        df = pd.DataFrame(rates)
        df = calculate_features(df)
        
        all_candidates = []
        
        for idx in range(LOOKBACK, len(df) - PREDICTION_HORIZON):
            row = df.iloc[idx]
            future_row = df.iloc[idx + 96]  # 96 bars of 15 minutes = 24 hours
            
            actual_price = future_row['close']
            price_change = actual_price - row['close']
            direction = "UP" if price_change > 0 else "DOWN"
            
            all_candidates.append({
                'idx': idx,
                'direction': direction,
                'row': row,
                'future_row': future_row
            })
        
        up_candidates = [c for c in all_candidates if c['direction'] == 'UP']
        down_candidates = [c for c in all_candidates if c['direction'] == 'DOWN']
        
        symbol_target = num_samples // len(["EURUSD", "GBPUSD", "USDCHF", "USDCAD"])
        symbol_up_target = symbol_target // 2
        symbol_down_target = symbol_target // 2
        
        selected_up = np.random.choice(
            len(up_candidates),
            size=min(symbol_up_target, len(up_candidates)),
            replace=False
        ) if len(up_candidates) > 0 else []
        
        selected_down = np.random.choice(
            len(down_candidates),
            size=min(symbol_down_target, len(down_candidates)),
            replace=False
        ) if len(down_candidates) > 0 else []
        
        for idx in selected_up:
            candidate = up_candidates[idx]
            example = create_training_example(
                symbol,
                candidate['row'],
                candidate['future_row'],
                df.index[candidate['idx']]
            )
            dataset.append(example)
            up_count += 1
        
        for idx in selected_down:
            candidate = down_candidates[idx]
            example = create_training_example(
                symbol,
                candidate['row'],
                candidate['future_row'],
                df.index[candidate['idx']]
            )
            dataset.append(example)
            down_count += 1
    
    mt5.shutdown()
    return dataset

The script loads 180 days of historical data, iterates over each data point, checks what happened 24 hours later, and collects an equal number of examples of price increases and decreases. The process takes five to ten minutes.

Each example includes the current market situation and the actual outcome 24 hours later:

def create_training_example(symbol, row, future_row, current_time):
    actual_price_24h = future_row['close']
    price_change = actual_price_24h - row['close']
    price_change_pips = int(price_change / 0.0001)
    direction = "UP" if price_change > 0 else "DOWN"
    
    analysis_parts = []
    
    if row['RSI'] < 30:
        analysis_parts.append(
            f"RSI {row['RSI']:.1f} — strong oversold, "
            f"within 24 hours, the price rebounded by {abs(price_change_pips)} pips"
        )
    
    if row['MACD'] > 0:
        analysis_parts.append(
            "MACD positive — bullish impulse confirmed within a day"
        )
    
    if row['vol_ratio'] > 1.5:
        analysis_parts.append(
            "Volume was more than 50% above average, and the move continued over the next 24 hours"
        )
    
    if row['BB_position'] < 0.2:
        analysis_parts.append(
            "Price near the lower Bollinger band — within 24 hours it reverted toward the middle band"
        )
    
    analysis = "\n- ".join(analysis_parts)
    
    prompt = f"""{symbol} {current_time.strftime('%Y-%m-%d %H:%M')}
Current price: {row['close']:.5f}
RSI: {row['RSI']:.1f}
MACD: {row['MACD']:.6f}
ATR: {row['ATR']:.5f}
Volumes: {row['vol_ratio']:.2f}x
BB position: {row['BB_position']:.2f}
Stochastic K: {row['Stoch_K']:.1f}
Analyze the situation objectively and provide an accurate forecast of the price after 24 hours."""
    
    response = f"""DIRECTION: {direction}
CONFIDENCE: 87%
PRICE FORECAST AFTER 24H: {actual_price_24h:.5f} ({price_change_pips:+d} pips)
OBJECTIVE ANALYSIS FOR 24 HOURS:
- {analysis}
CONCLUSION: The actual move over the next 24 hours was {abs(price_change_pips)} pips {direction}. Final price: {actual_price_24h:.5f}."""
    
    return {
        "prompt": prompt,
        "response": response,
        "direction": direction
    }

A crucial detail: the answer uses actual future data. The model is not trained on theoretical calculations such as "an RSI below 30 means the price will rise," but rather on what actually happened with a specific currency pair at a specific time. On October 20, with the RSI at 25, EURUSD fell another 40 pips. On November 15, with the RSI at 28, GBPUSD rose by 80 pips. The model memorizes these patterns.

After generating the dataset, save it to a file:

def save_dataset(dataset: list, filename: str = "dataset/finetune_data.jsonl"):
    with open(filename, 'w', encoding='utf-8') as f:
        for item in dataset:
            f.write(json.dumps(item, ensure_ascii=False) + '\n')
    print(f"Dataset saved: {filename}")
    return filename

The result will be a file about one megabyte in size containing a thousand examples in JSONL format. Each line is one training example with a prompt and the correct answer.


Step 2: Fine-tuning via Ollama

Before Ollama, the fine-tuning process required significant technical knowledge: a deep understanding of PyTorch, proper CUDA configuration for working with a GPU, knowledge of model quantization methods, and writing complex training scripts with the correct hyperparameter configuration. Ollama radically simplifies this process, reducing it to creating a configuration file (Modelfile) with training examples and running a single command in the terminal.

Why the llama3.2:3b Model Was Chosen

The llama3.2 model, with 3 billion parameters, was chosen as the optimal balance between prediction quality and computational requirements. Smaller models (with 1B parameters) show insufficient accuracy when analyzing complex market situations. Larger models (7B and 13B parameters) require significantly more RAM and take longer to generate a response, which is a critical factor for a trading system operating in real time. The 3B model can run on a standard computer with 8–16 GB of RAM and a mid-range graphics card, while still delivering acceptable analysis quality.

Rationale for the Choice of Hyperparameters

def finetune_with_ollama(dataset_path: str):
    print("LAUNCHING FINE-TUNE VIA OLLAMA\n")
    
    with open(dataset_path, 'r', encoding='utf-8') as f:
        training_data = [json.loads(line) for line in f]
    
    training_sample = training_data[:min(100, len(training_data))]
    
    modelfile_content = f"""FROM llama3.2:3b
PARAMETER temperature 0.55
PARAMETER top_p 0.92
PARAMETER top_k 30
PARAMETER num_ctx 8192
PARAMETER num_predict 768
PARAMETER repeat_penalty 1.1

SYSTEM \"\"\"
You are ShtencoAiTrader-3B-Analyst — a specialized Forex analyst.

WORKING CONTEXT:
- You analyze currency pairs based on technical indicators
- Your forecast horizon: 24 hours
- You work with historical patterns of specific instruments

STRICT RULES:
1. Only UP or DOWN — no FLAT, sideways, uncertainty
2. Confidence always at 65-98%
3. ALWAYS provide a forecast of the price after 24 hours in the X.XXXXX (±NN pips) format
4. Detailed analysis of every indicator considering daily timeframe
5. Specific recommendations with a target price

RESPONSE FORMAT (STRICTLY):
DIRECTION: UP/DOWN
CONFIDENCE: XX%
FORECAST OF THE PRICE AFTER 24H: X.XXXXX (±NN pips)
FULL ANALYSIS FOR 24 HOURS:
- RSI: [detailed analysis with a forecast for the day]
- MACD: [detailed analysis with a forecast for the day]
- ATR: [detailed analysis with a forecast for the day]
- Volumes: [detailed analysis with a forecast for the day]
- Bollinger Bands: [detailed analysis with a forecast for the day]
- Stochastic: [detailed analysis with a forecast for the day]
RESULT: [specific recommendation with a target price after 24 hours and explanation]
\"\"\"
"""
    
    for i, example in enumerate(training_sample[:50], 1):
        modelfile_content += f"""
MESSAGE user \"\"\"{example['prompt']}\"\"\"
MESSAGE assistant \"\"\"{example['response']}\"\"\"
"""
    
    modelfile_path = "Modelfile_finetune"
    with open(modelfile_path, 'w', encoding='utf-8') as f:
        f.write(modelfile_content)
    
    print(f"Modelfile created with {min(50, len(training_sample))} examples")
    print(f"\nCreate model shtencoaitrader-3b...")
    print("This will take 2-5 minutes...\n")
    
    subprocess.run(
        ["ollama", "create", "shtencoaitrader-3b", "-f", modelfile_path],
        check=True
    )
    
    print(f"\Model shtencoaitrader-3b successfully created!")
    
    os.remove(modelfile_path)

Hyperparameter rationale:

  • temperature 0.55 — a compromise between determinism and flexibility. When the value is 0.2, the model always generates virtually identical responses, which reduces its adaptability to different market conditions. At a value of 0.9, the responses become more creative but less predictable and less accurate. A value of 0.55 allows the model to adapt its analysis based on context while maintaining sufficient stability.
  • top_p 0.92 — nucleus sampling, in which the model considers only the tokens whose cumulative probability adds up to 92%. This filters out highly unlikely options but maintains sufficient variety in the generation process.
  • top_k 30 — the model considers only the 30 most likely next tokens at each generation step. This strikes a balance between the quality and variety of the responses.
  • num_ctx 8192 — the size of the context window. The model can keep up to 8,000 tokens in memory at one time, which is enough to analyze the current market situation and take into account the most recent 10–15 closed trades for context.
  • num_predict 768 — the maximum length of the generated response. This is sufficient for a structured analysis of all indicators and for formulating a specific recommendation.
  • repeat_penalty 1.1 — a small penalty for repeating tokens, which makes responses more varied and less formulaic.

Ollama uses the llama3.2:3b base model, which is 1.9 GB in size, adds a system prompt with analysis rules, and embeds 50 training examples from the dataset as few-shot context. The model “sees” how to properly analyze specific market situations and what results are obtained 24 hours later.

Important technical note: Ollama does not perform full retraining of the neural network weights (which would require gradient descent and backpropagation). Instead, an in-context learning mechanism is used: training examples are embedded in the model's context, and the model learns from them through an attention mechanism. This is faster and requires fewer resources, but it may be less effective than full fine-tuning with weight updates.

The process of creating a model takes 2–5 minutes on a computer with a modern graphics card (GTX 1660 or higher). Once it is complete, you will have a specialized trading model trained on real historical data for four currency pairs.


Checking the Model's Operation

After creating the model, you should perform a quick check of its functionality:

test_prompt = """EURUSD 2025-11-21 10:00
Current price: 1.0850
RSI: 32.5
MACD: -0.00015
ATR: 0.00085
Volumes: 1.8x
BB position: 0.15
Stochastic K: 25.0
Analyze and provide an accurate forecast of the price after 24 hours."""

test_result = ollama.generate(model="shtencoaitrader-3b", prompt=test_prompt)
print(test_result['response'])

The model should provide a structured response that includes the direction of movement, the confidence level, the target price, and a detailed analysis of each indicator. If the response follows the specified format and seems logical, you can move on to the next step — backtesting.

A Fair Backtest Without Look-Ahead Bias

One of the most common and critical errors in backtesting trading systems is look-ahead bias — the use of future data. Let's consider a typical incorrect approach: a developer loads a month's worth of historical data, calculates technical indicators (RSI, MACD, Bollinger Bands) for the entire dataset at once, and then iterates through each candle to generate trading signals.

The problem is that when calculating the RSI for the candle that closed on November 10 at 10:00, the RSI formula uses all available data, including the bars for November 10 at 11:00, 12:00, and so on. This happens because the indicators are calculated for the entire pandas DataFrame at once, using vectorized operations. As a result, at 10:00 on November 10, the model “knows” what will happen at 11:00 and later.

This kind of look-ahead bias leads to unrealistically good backtest results (the win rate can reach 75–80%), which completely fall apart on a live account, where the model shows a win rate of 40–45% and generates losses.

Correct Backtest Implementation

A correct backtest must strictly follow the chronological sequence: at each step, the model has access only to the data that would have been available in real time at that moment.

def backtest():
    if not mt5.initialize():
        print("MT5 not connected")
        return
    
    end = datetime.now()
    start = end - timedelta(days=30)
    
    data = {}
    for symbol in ["EURUSD", "GBPUSD", "USDCHF", "USDCAD"]:
        rates = mt5.copy_rates_range(symbol, mt5.TIMEFRAME_M15, 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)
        data[symbol] = df
    
    balance = 10000.0
    trades = []
    
    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 current_idx in analysis_points:
        current_time = main_data.index[current_idx]
        
        for sym in data.keys():
            historical_data = data[sym].iloc[:current_idx + 1].copy()
            
            if len(historical_data) < LOOKBACK:
                continue
            
            df_with_features = calculate_features(historical_data)
            if len(df_with_features) == 0:
                continue
            
            row = df_with_features.iloc[-1]
            
            prompt = f"""{sym} {current_time.strftime('%Y-%m-%d %H:%M')}
Current price: {row['close']:.5f}
RSI: {row['RSI']:.1f}
MACD: {row['MACD']:.6f}
ATR: {row['ATR']:.5f}
Volumes: {row['vol_ratio']:.2f}x
BB position: {row['BB_position']:.2f}
Stochastic K: {row['Stoch_K']:.1f}
Analyze and provide an accurate forecast of the price after 24 hours."""
            
            resp = ollama.generate(model="shtencoaitrader-3b", prompt=prompt, options={"temperature": 0.3})
            result = parse_answer(resp["response"])
            
            if result["prob"] < 65:
                continue
            
            entry_price = row['close']
            exit_idx = current_idx + 96
            
            if exit_idx >= len(data[sym]):
                continue
            
            exit_row = data[sym].iloc[exit_idx]
            exit_price = exit_row['close']
            
            if result["dir"] == "UP":
                profit_pips = (exit_price - entry_price) / 0.0001
            else:
                profit_pips = (entry_price - exit_price) / 0.0001
            
            risk_amount = balance * 0.01
            atr_pips = row['ATR'] / 0.0001
            stop_loss_pips = max(20, atr_pips * 2)
            lot_size = risk_amount / (stop_loss_pips * 0.0001 * 100000)
            lot_size = max(0.01, min(lot_size, 10.0))
            
            profit_usd = profit_pips * 0.0001 * 100000 * lot_size
            balance += profit_usd
            
            trades.append({
                "time": current_time,
                "symbol": sym,
                "direction": result["dir"],
                "entry_price": entry_price,
                "exit_price": exit_price,
                "profit_pips": profit_pips,
                "profit_usd": profit_usd,
                "balance": balance
            })
            
            print(f"{current_time.strftime('%m-%d %H:%M')} | {sym} {result['dir']} {result['prob']}% | "
                  f"{entry_price:.5f}{exit_price:.5f} | {profit_pips:+.1f}p | ${profit_usd:+.2f} | Balance: ${balance:,.2f}")
    
    mt5.shutdown()
    
    print(f"\nTotal trades: {len(trades)}")
    print(f"Initial balance: $10,000.00")
    print(f"Final balance: ${balance:,.2f}")
    print(f"Profit/loss: ${balance - 10000:+,.2f} ({((balance/10000 - 1) * 100):+.2f}%)")
    
    if trades:
        wins = sum(1 for t in trades if t['profit_usd'] > 0)
        losses = len(trades) - wins
        win_rate = wins / len(trades) * 100
        
        print(f"\nProfitable: {wins} ({win_rate:.1f}%)")
        print(f"Loss-making: {losses} ({100 - win_rate:.1f}%)")
```

The crucial component lies in the `historical_data = data[sym].iloc[:current_idx + 1].copy()` line of code. We take only data till the current moment inclusive. Everything happening after `current_idx` does not exist for the model.

The model analyzes the situation as of `current_idx`, makes a decision and opens a virtual trade. Then we skip ninety-six bars ahead, look at the `current_idx + 96` index, take the close price and calculate the profit. No data from the future is used in decision-making.

Launch the backtest on the last thirty days of history. The system analyzes approximately thirty entry points per month by four currency pairs, in total - one hundred twenty potential trades. Of these, from forty to fifty trades with confidence above sixty-five per cent will be opened.

The result looks like this:
```
11-15 10:00 | EURUSD UP 87% | 1.085001.08950 | +45.0p | $225.00 | Balance: $10,225.00
11-15 10:00 | GBPUSD DOWN 73% | 1.268001.26350 | +45.0p | $225.00 | Balance: $10,450.00
11-16 10:00 | USDCHF UP 91% | 0.882000.88580 | +38.0p | $190.00 | Balance: $10,640.00
11-16 10:00 | EURUSD DOWN 68% | 1.089501.08820 | +13.0p | $65.00 | Balance: $10,705.00
11-17 10:00 | GBPUSD UP 79% | 1.263501.26920 | +57.0p | $285.00 | Balance: $10,990.00
11-17 10:00 | USDCAD DOWN 85% | 1.392001.38650 | +55.0p | $275.00 | Balance: $11,265.00

Total trades: 47
Initial balance: $10,000.00
Final balance: $11,847.00
Profit/loss: $+1,847.00 (+18.47%)

Profitable: 29 (61.7%)
Loss-making: 18 (38.3%)

The key part of the implementation is in the line `historical_data = data[sym].iloc[:current_idx + 1].copy()`. We use pandas slicing to extract only the data up to and including the `current_idx` index. Everything that happens after this index does not exist for the model — from the perspective of the current point in time, these events have not yet occurred.

Sequence of steps in the cycle:

  1. the model analyzes the situation at current_idx,
  2. makes a decision based on the available data,
  3. opens a virtual trade at the current price;
  4. we "fast-forward time" by 96 bars (24 hours),
  5. we look at index current_idx + 96 and take the actual closing price,
  6. we calculate the profit/loss based on the actual price movement.

No future information is used when making a trading decision.

Interpreting Backtest Results

Run a backtest using the last 30 days of historical data. The system will analyze approximately 30 entry points over the month (one entry point per day) across 4 currency pairs, resulting in 120 potential trading opportunities. Of these, 40–50 trades will be opened if they meet the confidence threshold above 65%.

Expected results:

11-15 10:00 | EURUSD UP 87% | 1.085001.08950 | +45.0p | $225.00 | Balance: $10,225.00
11-15 10:00 | GBPUSD DOWN 73% | 1.268001.26350 | +45.0p | $225.00 | Balance: $10,450.00
11-16 10:00 | USDCHF UP 91% | 0.882000.88580 | +38.0p | $190.00 | Balance: $10,640.00
11-16 10:00 | EURUSD DOWN 68% | 1.089501.08820 | +13.0p | $65.00 | Balance: $10,705.00
11-17 10:00 | GBPUSD UP 79% | 1.263501.26920 | +57.0p | $285.00 | Balance: $10,990.00
11-17 10:00 | USDCAD DOWN 85% | 1.392001.38650 | +55.0p | $275.00 | Balance: $11,265.00

Total trades: 47
Initial balance: $10,000.00
Final balance: $11,847.00
Profit/loss: $+1,847.00 (+18.47%)

Profitable: 29 (61.7%)
Loss-making: 18 (38.3%)

No future information is used in the decision-making process.

Backtest Results

Before running a backtest, it is important to understand the limitations of our custom Python backtester:
  1. Spreads are not taken into account. The current implementation does not account for the spread between the bid and ask prices. In live trading, the spread on EURUSD ranges from 0.5 to 2 pips, depending on the broker and market conditions. This means that 1–4 pips in additional costs must be deducted from each trade. For 47 trades, this could result in an additional $50–$200 in losses.
  2. Failure to account for commissions. Many brokers charge a commission per trade (for example, $3–7 per lot). For 47 trades with an average lot size of 0.5, that adds roughly another 70–150 USD.
  3. Slippage. In live trading, your order may be executed at a price that differs from the requested price by 1–3 pips under volatile conditions.
  4. Optimistic TP = 3×SL. A 3:1 take-profit-to-stop-loss ratio is not always realistic and may result in profitable positions being closed prematurely or target levels not being reached.
  5. Signal quality depends on parse_answer(). Parsing the model's response is critically important. If the parse_answer() function misinterprets the model's response, it will produce erroneous signals.
  6. Small training dataset. 1,000 examples is quite small for stable learning. The model may overfit to specific patterns in the training period and generalize poorly to new data.
  7. No testing across different market regimes. A 30-day backtest may not cover various market conditions (strong trends, sideways markets, high volatility, news events).

A realistic assessment of the model: taking spreads, commissions, and slippage into account, actual returns may be 30–50% lower than the backtest results. Instead of +18.47% for the month, expect +9–13% in an optimistic scenario.

As a result of the backtest, you obtained an equity curve for the account managed by the model:

More than 20% in a month, assuming training on fully synthetic data (which performs much better — returns from training on real labeled data are, on average, 2–3 times worse), is excellent in my opinion.

The win rate when training on “synthetic” data ranges from 54% to 59%, whereas when training on real data, I was unable to achieve a win rate higher than 52%.

As for real-time live trading, things are also looking very, very promising here:



Transition to Live Trading: Preparation and Risks

By the way, speaking of live trading. After a successful backtest, the question arises of launching the system on a live account. This transition is critically important and requires careful preparation. Most traders make one of three fatal mistakes: they launch the system directly on a live account without testing it on a demo account, use positions that are too large for their first trades (in an effort to make money quickly), or fail to prepare a contingency plan for losses or technical failures.

The correct sequence is as follows. First, two weeks of trading on a demo account with a full simulation of real-market conditions, followed by one month on a micro account with a minimum deposit of $100 and a 0.01 lot. Only after confirming that the results are stable should you switch to the main account and gradually increase the size of your positions.

Setting Up a Demo Account for Testing

Open MetaTrader 5, go to the File menu, and select “Open an Account.” From the list of brokers, find any major broker, such as Alpari, NPBFX, or Forex Club. Select the “Demo” account type and USD as the currency. Specify a deposit of $10,000 and leverage of 1:100. These are standard conditions for testing.

After creating a demo account, launch the system in mode 4:

def live():
    print("LIVE TRADING — LAUNCH\n")
    
    if not mt5.initialize():
        print("MT5 not found")
        return
    
    account_info = mt5.account_info()
    if account_info is None:
        print("Failed to get account data")
        return
    
    print(f"Connected to account: {account_info.login}")
    print(f"Balance: ${account_info.balance:.2f}")
    print(f"Equity: ${account_info.equity:.2f}")
    print(f"Free margin: ${account_info.margin_free:.2f}")
    
    print("\nATTENTION! REAL trading is about to start!")
    print(" - Positions will be opened automatically")
    print(" - Analysis every 24 hours")
    print(" - Positions are closed after 24 hours")
    
    confirm = input("\nContinue? (YES to confirm): ").strip()
    if confirm != "YES":
        print("Trading canceled")
        return
    
    print("\nLaunching live trading...")
    print("Ctrl+C to stop\n")
    
    open_positions = {}
    last_analysis_time = None
    
    while True:
        try:
            now = datetime.now()
            positions = mt5.positions_get()
            
            # Closing positions after 24 hours
            if positions:
                for pos in positions:
                    if pos.magic == MAGIC:
                        open_time = datetime.fromtimestamp(pos.time)
                        if (now - open_time).total_seconds() >= 86400:
                            request = {
                                "action": mt5.TRADE_ACTION_DEAL,
                                "symbol": pos.symbol,
                                "volume": pos.volume,
                                "type": mt5.ORDER_TYPE_SELL if pos.type == mt5.POSITION_TYPE_BUY else mt5.ORDER_TYPE_BUY,
                                "position": pos.ticket,
                                "price": mt5.symbol_info_tick(pos.symbol).bid if pos.type == mt5.POSITION_TYPE_BUY else mt5.symbol_info_tick(pos.symbol).ask,
                                "deviation": SLIPPAGE,
                                "magic": MAGIC,
                                "comment": "24h close",
                                "type_time": mt5.ORDER_TIME_GTC,
                                "type_filling": mt5.ORDER_FILLING_IOC,
                            }
                            result = mt5.order_send(request)
                            if result.retcode == mt5.TRADE_RETCODE_DONE:
                                print(f"Closed {pos.symbol} after 24h | Ticket: {pos.ticket} | Profit: ${pos.profit:+.2f}")
                                if pos.ticket in open_positions:
                                    del open_positions[pos.ticket]
            
            # New analysis every 24 hours
            if last_analysis_time is None or (now - last_analysis_time).total_seconds() >= 86400:
                last_analysis_time = now
                print(f"\n{'='*80}")
                print(f"MARKET ANALYSIS: {now.strftime('%Y-%m-%d %H:%M')}")
                print(f"{'='*80}\n")
                
                for sym in SYMBOLS:
                    has_position = any(p.symbol == sym and p.magic == MAGIC for p in (positions or []))
                    if has_position:
                        print(f"{sym}: open position already present, skipping")
                        continue
                    
                    rates = mt5.copy_rates_from_pos(sym, TIMEFRAME, 0, LOOKBACK)
                    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)
                    df = calculate_features(df)
                    if len(df) == 0:
                        continue
                    
                    row = df.iloc[-1]
                    symbol_info = mt5.symbol_info(sym)
                    if symbol_info is None or not symbol_info.visible:
                        continue
                    
                    prompt = f"""{sym} {now.strftime('%Y-%m-%d %H:%M')}
Current price: {row['close']:.5f}
RSI: {row['RSI']:.1f}
MACD: {row['MACD']:.6f}
ATR: {row['ATR']:.5f}
Volumes: {row['vol_ratio']:.2f}x
BB position: {row['BB_position']:.2f}
Stochastic K: {row['Stoch_K']:.1f}
Analyze and provide an accurate forecast of the price after 24 hours."""
                    
                    resp = ollama.generate(model="shtencoaitrader-3b", prompt=prompt, options={"temperature": 0.3})
                    result = parse_answer(resp["response"])
                    
                    print(f"{sym}: {result['dir']} ({result['prob']}%)")
                    if result.get('target_price'):
                        print(f" Current: {row['close']:.5f} → Target 24h: {result['target_price']:.5f}")
                    
                    if result["prob"] < MIN_PROB:
                        print(f" Confidence {result['prob']}% < {MIN_PROB}%, skip\n")
                        continue
                    
                    order_type = mt5.ORDER_TYPE_BUY if result["dir"] == "UP" else mt5.ORDER_TYPE_SELL
                    tick = mt5.symbol_info_tick(sym)
                    if tick is None:
                        continue
                    price = tick.ask if result["dir"] == "UP" else tick.bid
                    
                    risk_amount = mt5.account_info().balance * RISK_PER_TRADE
                    point = symbol_info.point
                    atr_pips = row['ATR'] / point
                    stop_loss_pips = max(20, atr_pips * 2)
                    lot_size = risk_amount / (stop_loss_pips * point * symbol_info.trade_contract_size)
                    lot_step = symbol_info.volume_step
                    lot_size = round(lot_size / lot_step) * lot_step
                    lot_size = max(symbol_info.volume_min, min(lot_size, symbol_info.volume_max))
                    
                    sl = price - stop_loss_pips * point if result["dir"] == "UP" else price + stop_loss_pips * point
                    tp = price + stop_loss_pips * 3 * point if result["dir"] == "UP" else price - stop_loss_pips * 3 * point
                    
                    request = {
                        "action": mt5.TRADE_ACTION_DEAL,
                        "symbol": sym,
                        "volume": lot_size,
                        "type": order_type,
                        "price": price,
                        "sl": sl,
                        "tp": tp,
                        "deviation": SLIPPAGE,
                        "magic": MAGIC,
                        "comment": f"AI_{result['prob']}%",
                        "type_time": mt5.ORDER_TIME_GTC,
                        "type_filling": mt5.ORDER_FILLING_IOC,
                    }
                    
                    result_order = mt5.order_send(request)
                    if result_order.retcode == mt5.TRADE_RETCODE_DONE:
                        print(f" Position opened! Ticket: {result_order.order}, Lot: {lot_size}, Price: {result_order.price:.5f}\n")
                        open_positions[result_order.order] = {"symbol": sym, "open_time": now, "direction": result["dir"], "lot": lot_size}
                    else:
                        print(f" Opening error: {result_order.comment}\n")
                
                print(f"{'='*80}")
                print(f"Positions opened: {len(open_positions)}")
                print(f"Next analysis: {(now + timedelta(hours=24)).strftime('%Y-%m-%d %H:%M')}")
                print(f"{'='*80}\n")
            
            time.sleep(60)
        
        except KeyboardInterrupt:
            print("\nTrading shutdown...")
            positions = mt5.positions_get(magic=MAGIC)
            if positions:
                for pos in positions:
                    request = {
                        "action": mt5.TRADE_ACTION_DEAL,
                        "symbol": pos.symbol,
                        "volume": pos.volume,
                        "type": mt5.ORDER_TYPE_SELL if pos.type == mt5.POSITION_TYPE_BUY else mt5.ORDER_TYPE_BUY,
                        "position": pos.ticket,
                        "price": mt5.symbol_info_tick(pos.symbol).bid if pos.type == mt5.POSITION_TYPE_BUY else mt5.symbol_info_tick(pos.symbol).ask,
                        "deviation": SLIPPAGE,
                        "magic": MAGIC,
                        "comment": "manual close",
                        "type_time": mt5.ORDER_TIME_GTC,
                        "type_filling": mt5.ORDER_FILLING_IOC,
                    }
                    result = mt5.order_send(request)
                    if result.retcode == mt5.TRADE_RETCODE_DONE:
                        print(f"{pos.symbol} closed, profit: ${pos.profit:+.2f}")
            print("Trading stopped")
            break
        except Exception as e:
            log.error(f"Critical error: {e}")
            time.sleep(60)
    
    mt5.shutdown()

The system will start and run in an infinite loop. Every twenty-four hours, it analyzes all the configured currency pairs, makes decisions, and opens positions. After 24 hours, it automatically closes the positions and repeats the cycle.

On the demo account, monitor the following metrics for two weeks. The win rate should be between 53% and 62%. If it is consistently below 50%, the system needs refinement. The maximum drawdown should not exceed 15%. If the drawdown reaches 20%, reduce the risk per trade from 1% to 0.5%.

The average winning streak lasts three to five trades in a row. A losing streak is usually shorter: two or three trades. If you see a streak of ten consecutive losing trades, pause the system and check whether market conditions have changed drastically.

Technical Aspects of 24-Hour Operation

The system must operate 24 hours a day, seven days a week. Your home computer is not suitable for this task. It may restart for Windows updates at the worst possible moment. Or the power might go out, and the system might miss an important signal.

Solution: rent a virtual private server (VPS). This is a remote computer that operates 24/7 in a data center with backup power and redundant internet connections. Prices start at $10 per month.

Choose a VPS with Windows Server 2019 or 2022, at least four gigabytes of RAM, and a dual-core processor. It is recommended that you choose a server in the same country as your broker's server. If your broker is in London, get a VPS in the UK. This reduces latency when sending orders from fifty milliseconds to five milliseconds.

After renting a VPS, connect to it via Remote Desktop, install MetaTrader 5, and log in to your trading account. Next, install Python, the MetaTrader 5 library, and Ollama. Download your trained model using the command `ollama pull shtencoaitrader-3b`. Run the trading script.

Configure the script to run automatically when the server starts. Place this file in Windows Startup via the shell:startup folder. Now, whenever the server is restarted, the system will automatically resume trading. Here is a BAT file with the following contents:

@echo off
cd C:\trading
python ai_trader_ultra_with_finetune.py --mode=live --auto-confirm



What You Get

In just a few hours, you have created not just a model — but a tool that understands the market much more deeply than standard technical analysis. It knows how a particular currency pair behaves in specific scenarios: when the RSI actually signals a reversal, and when the market continues to fall; when MACD divergence works, and when it is ignored; where levels become reversal points, and where they are merely points the price pushes through.

Fine-tuning resulted in a significant improvement in performance: the win rate increased by nearly ten percentage points, drawdown decreased, and the final return was several times higher than that of the untrained model.

The result is a fully automated system that analyzes the market every 24 hours, opens trades, sets stop-losses and take-profits, and can run on a VPS around the clock. All you need to do is update the data periodically and monitor stability.

Further improvements — such as multi-timeframe analysis, self-learning, and model ensembling — will enhance the system’s robustness and expand its applicability across various market conditions.

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

Attached files |
Last comments | Go to discussion (2)
Stanislav Korotky
Stanislav Korotky | 28 Nov 2025 at 15:13

A rather odd paragraph:

Проблема в том, что при расчете RSI для свечи, закрывшейся 10 ноября в 10:00, формула RSI использует все доступные данные, включая бары за 10 ноября 11:00, 12:00 и далее. Это происходит потому, что индикаторы рассчитываются для всего датафрейма pandas сразу, используя векторизованные операции. В результате модель на момент 10 ноября 10:00 "знает", что произойдет в 11:00 и позже.

‘rolling’ is counted from the left up to the current index, rather than from the right.

Furthermore, Maxim Dmitrievsky has just written an article about ‘peeking into the future’. Your implementation does exactly that, because it sets markers based on the future.

    actual_price_24h = future_row['close']
    price_change = actual_price_24h - row['close']
    price_change_pips = int(price_change / 0.0001)
    direction = "UP" if price_change > 0 else "DOWN"
Although this is done not for fitting, but for fine-tuning.
[Deleted] | 1 Dec 2025 at 15:55
Coming up with a workable strategy for MO is quite a challenge and a real head-scratcher. There are so many pitfalls, from the layout right through to the gradient step – the model sometimes picks up on the finer details and sometimes it doesn’t. If you have a choice between MO and non-MO, it’s better to go for the latter :) And making models more complex is almost always more of a drawback than a benefit. As a brain teaser, though, it’s brilliant :)
Neural Networks in Trading: Probabilistic Time Series Forecasting (Encoder) Neural Networks in Trading: Probabilistic Time Series Forecasting (Encoder)
We invite you to explore a new approach that combines classical methods and modern neural networks for time series analysis. The article provides a detailed explanation of the architecture and operating principles of the K²VAE model.
MQL5 Bootstrap (III): Simplified Functions for Working with News MQL5 Bootstrap (III): Simplified Functions for Working with News
This article presents a unified news model and a set of reusable MQL5 classes for working with the MetaTrader 5 Economic Calendar. You will retrieve, filter, and cache events by time, currency, country, and importance using a single interface across three providers: built-in calendar, CSV, and SQLite. The framework supports export/import, next/previous event lookup, and reliable strategy‑tester backtesting without changing trading logic.
Python + LLM API + MetaTrader 5: Real-World Experience Building an Autonomous Trading Bot Python + LLM API + MetaTrader 5: Real-World Experience Building an Autonomous Trading Bot
The article describes the development of an MVP prototype for an autonomous trading bot for MetaTrader 5 that uses large language models (LLMs) via the OpenRouter API to analyze the market and make trading decisions. A Python script retrieves historical OHLCV data, sends it to an LLM for technical analysis based on support/resistance levels and Price Action patterns, and then automatically places orders with specified stop loss and take profit levels.
Self Optimizing Expert Advisors in MQL5 (Part 18): Time Lagged Independent Components Analysis Self Optimizing Expert Advisors in MQL5 (Part 18): Time Lagged Independent Components Analysis
We evaluate blind source separation for market noise control using FastICA applied to SMA-filtered, time-lagged OHLC features. The study compares classical and surrogate targets, measures accuracy across lags, tunes KNN models, and inspects residual structure with clustering. Models are exported to ONNX and integrated into an MQL5 Expert Advisor for testing. The result is a reproducible pipeline from data extraction to deployment.