Python – Metatrader – Backtesting part 2

YouTube Video Thumbnail

The second stage of backtesting a python trading robot. I’m adding methods to allow the same trading robot to run in both a backtest mode and to run live against Metatrader 5.

My approach involves using child classes of the Backtest and Strategy classes, to intercept the trading actions and direct calls to either backtesting or Metatrader.

I began by copying the files from the last part where I introduced backtesting and just renaming files as MA Cross, then adding the file for the new classes

ma_cross.py
classes\
    ma_cross_strategy.py
    custom_backtesting.py

In custom_backtesting.py I created 2 classes, CustomBacktest and CustomStrategy. There are some imports at the beginning of the file

import MetaTrader5 as mt5
from datetime import datetime
import pandas as pd
import time
from typing import Optional, Tuple, Type, Union

from backtesting import Backtest, Strategy

CustomBacktest class

Then add the CustomBacktest class inheriting from Backtest and a constructor that sets up testing mode and fills with dummy data in live mode, finally calling the parent constructor

class CustomBacktest(Backtest):

    def __init__(self,
                 data: pd.DataFrame,
                 strategy: Type[Strategy],
                 *,
                 cash: float = 10_000,
                 spread: float = .0,
                 commission: Union[float, Tuple[float, float]] = .0,
                 margin: float = 1.,
                 trade_on_close=False,
                 hedging=False,
                 exclusive_orders=False,
                 finalize_trades=False,
                 ):
        
        self.is_testing = True

        # create a blank data set if needed
        if data is None:
            columns = ['Time', 'Open', 'High', 'Low', 'Close', 'Volume']
            values = {col: [0] for col in columns}
            values['Time'] = [datetime.now()]
            data = pd.DataFrame(values)
            data.set_index('Time', inplace=True)

        super().__init__(data, strategy,
                         cash=cash, 
                         spread=spread, 
                         commission=commission, 
                         margin=margin, 
                         trade_on_close=trade_on_close, 
                         hedging=hedging, 
                         exclusive_orders=exclusive_orders, 
                         finalize_trades=finalize_trades)

To switch into live mode add a function

    def set_live_params(self, *, symbol='', timeframe=mt5.TIMEFRAME_H1, cycle=60):
        self.is_testing = False
        self.symbol = symbol
        self.timeframe = timeframe
        self.cycle = cycle

Then override the run function from the parent class if running in live mode, or just call the parent class if in test mode

    def run(self, **kwargs) -> pd.Series:

        if self.is_testing:
            return super().run(**kwargs)
        
        if not mt5.symbol_select(self.symbol):
             return None
        
        strategy: Strategy = self._strategy(None, self._data, kwargs)
        strategy.set_live_params(symbol=self.symbol, timeframe=self.timeframe)

        strategy.init()

        while True:
            strategy.next()
            time.sleep(self.cycle)

        return None

CustomStrategy class

The constructor also sets up testing mode and just calls the parent constructor

class CustomStrategy(Strategy):

    def __init__(self, broker, data, params):
        super().__init__(broker, data, params)

        self.is_testing = True

There is also a function to set live mode

    def set_live_params(self, *, symbol='', timeframe=mt5.TIMEFRAME_H1):
        self.is_testing = False
        self.symbol = symbol
        self.timeframe = timeframe

The implementation of the strategy is in the MACross class which currently inherits from the Strategy class. That will change to inherit from CustomStrategy but the MACross class calls some functions to get trade information and to execute buy and sell. These functions need to be handled differently in CustomStrategy depending on if they are live or test

    def buy(self, *,
            size: float = 1.0,
            limit: Optional[float] = None,
            stop: Optional[float] = None,
            sl: Optional[float] = None,
            tp: Optional[float] = None,
            tag: object = None) -> 'Order':
        
        if self.is_testing:
            return super().buy(size=size, limit=limit, stop=stop, sl=sl, tp=tp, tag=tag)
        
        return self.open_position(mt5.ORDER_TYPE_BUY, size, sl, tp, 0, 0)

    def sell(self, *,
             size: float = 1.0,
             limit: Optional[float] = None,
             stop: Optional[float] = None,
             sl: Optional[float] = None,
             tp: Optional[float] = None,
             tag: object = None) -> 'Order':

        if self.is_testing:
            return super().sell(size=size, limit=limit, stop=stop, sl=sl, tp=tp, tag=tag)

        return self.open_position(mt5.ORDER_TYPE_SELL, size, sl, tp, 0, 0)

    def open_position(self, type, size, sl, tp, deviation, magic):
        
        request = self.get_request(type, size, sl, tp, deviation, magic)
        if request is None:
            return

        result = mt5.order_send(request)

        if result.retcode != mt5.TRADE_RETCODE_DONE:
            print(f"order send failed with {result.retcode}")
            print(result)
            # return False
        else:
            print("we had success")
            print(result)

    def get_request(self, type, size, sl, tp, deviation, magic):

        # current price information
        price_info = mt5.symbol_info_tick(self.symbol)
        if price_info is None:
            print(f"Failed to get price information for {self.symbol}")
            return None
        
        if type == mt5.ORDER_TYPE_BUY:
            price = price_info.ask
        else:
            price = price_info.bid

        request = {
            "action": mt5.TRADE_ACTION_DEAL,
            "symbol": self.symbol,
            "magic": magic,
            "volume": size,
            "type": type,
            "price": price,
            "sl": sl,
            "tp": tp,
            "deviation": deviation,
            "type_time": mt5.ORDER_TIME_GTC,
        }

        if not self.set_filling_mode(request):
            return None

        return request

    def set_filling_mode(self, request):

        for filling_mode in range(2):
            request['type_filling'] = filling_mode
            result = mt5.order_check(request)
            
            if result.comment == "Done":
                return True
        
        return False

    def get_rates_from_pos(self, symbol, timeframe, bar_count):

        rates = mt5.copy_rates_from_pos(symbol, timeframe, 0, bar_count)        
        if rates is None:
            print('No rates data retrieved')
            return None

        rates_frame = pd.DataFrame(rates)
        rates_frame.rename(columns={'open':'Open', 'high':'High', 'low':'Low', 'close':'Close', 'tick_volume':'Volume'}, inplace=True)

        return rates_frame

    # common functions that will take different paths on test
    def get_open_trade_count(self, type):

        open_trades = 0

        if self.is_testing:
            if type==mt5.ORDER_TYPE_BUY:
                open_trades = sum(1 for trade in self.trades if trade.is_long)
            elif type==mt5.ORDER_TYPE_SELL:
                open_trades = sum(1 for trade in self.trades if trade.is_long)
            return open_trades
        
        positions = mt5.positions_get(symbol=self.symbol)

        if positions == None:
            return 0
        
        open_trades = sum(1 for position in positions if position['type']==type)
        return open_trades

ma_cross.py

Here I have to setup the mode as testing or live. I also replaced references to Backtest with CustomBacktest and added some constants to use for flagging test mode and setting the environment for live trading

Imports and constants

import MetaTrader5 as mt5
import pandas as pd
import datetime

# from backtesting import Backtest
from classes.custom_backtesting import CustomBacktest
from classes.ma_cross_strategy import MACrossStrategy

TESTING=False
SYMBOL = 'EURUSD'
TIMEFRAME = mt5.TIMEFRAME_M1
CYCLE = 1


Change the section setting up history to only build the test data is in testing mode, only print test results in testing mode, and replace the Backtest call with CustomBacktest

def main():

    if not mt5.initialize():
        log("terminal initialisation failed")
        return
    log("MT5 successfully initialised")

    # get history data from mt, only using one instrument / tf for now
    if TESTING:
        history = pd.DataFrame(mt5.copy_rates_from_pos("EURUSD", mt5.TIMEFRAME_H1, 0, 1000))   
        history['time'] = pd.to_datetime(history['time'], unit='s')
        history.set_index('time', inplace=True)
        history.rename(columns={'open':'Open', 'high':'High', 'low':'Low', 'close':'Close', 'tick_volume':'Volume'}, inplace=True)
        print(history)
    else:
        history = None

    test = CustomBacktest(history, MACrossStrategy, cash=10000, hedging=True, finalize_trades=True)
    if not TESTING:
        test.set_live_params(symbol=SYMBOL, timeframe=TIMEFRAME, cycle=CYCLE)
    result = test.run()

    if TESTING:
        print(result)
        print(f'buy count = {result._strategy.buy_count}')
        print(f'sell count = {result._strategy.sell_count}')
    
    mt5.shutdown()
    return


ma_cross_strategy.py

The code changes in ma_cross_strategy.py are untidy and will be improved in future versions.

First change the imports, note that I’ve used pandas_ta here so listen to the warnings in this and the last video.

import MetaTrader5 as mt5
import pandas as pd
import pandas_ta as ta # take care with this

from backtesting._util import _Data
from classes.custom_backtesting import CustomStrategy


Then modify init to only calculate indicators if in testing mode

    def init(self):

        self.fast_ma_period = 5
        self.slow_ma_period = 10

        self.lot_size = 0.1
        self.stop_loss_amount = 0.00100
        self.take_profit_amount = 0.00150

        self.buy_count = 0
        self.sell_count = 0

        # Calculate fast and slow moving averages
        if self.is_testing:
            self.fast_ma = self.I(SMA, self.data.Close, self.fast_ma_period)
            self.slow_ma = self.I(SMA, self.data.Close, self.slow_ma_period)


Change the next function to calculate indicators and fill data on each call in live mode, and use a function to count the number of open trades

    def next(self):

        # get fresh data if in live mode
        if not self.is_testing:

            bar_count = self.fast_ma_period + self.slow_ma_period + 3
            rates = self.get_rates_from_pos(self.symbol, self.timeframe, bar_count)
            if rates is None:
                return
            
            self._data = _Data(rates.copy(deep=False))

            self.fast_ma = SMA(rates['Close'], self.fast_ma_period)
            self.slow_ma = SMA(rates['Close'], self.slow_ma_period)

        # Check for crossover
        if self.fast_ma[-1] < self.slow_ma[-1] and self.fast_ma[-2] >= self.slow_ma[-2]:

            # Fast MA crosses above Slow MA: Buy signal
            open_trades = self.get_open_trade_count(mt5.ORDER_TYPE_BUY)
            if open_trades > 0:
                return

            stop_loss_price = self.data.Close[-1]-self.stop_loss_amount
            take_profit_price = self.data.Close[-1]+self.take_profit_amount
            self.buy(size = self.lot_size, sl = stop_loss_price, tp = take_profit_price)
            self.buy_count += 1

        elif self.slow_ma[-1] < self.fast_ma[-1] and self.slow_ma[-2] >= self.fast_ma[-2]:

            # Slow MA crosses above Fast MA: Sell signal
            open_trades = self.get_open_trade_count(mt5.ORDER_TYPE_SELL)
            if open_trades > 0:
                return

            stop_loss_price = self.data.Close[-1]+self.stop_loss_amount
            take_profit_price = self.data.Close[-1]-self.take_profit_amount
            self.sell(size = self.lot_size, sl = stop_loss_price, tp = take_profit_price)
            self.sell_count += 1


Finally I changed the SMA function from last time to now use pandas_ta. Remember that at the time of recording pandas_ta does not work with versions of numpy from 2 onward so I created my own modified but untested version which you can install with the supplied commands, at your own risk, if you wish.

# Define Simple Moving Average (SMA) function
def SMA(data, period):
    return ta.sma(pd.Series(data), length = period).to_numpy()

# pip uninstall pandas_ta
# pip install -U git+https://github.com/OrchardForexTutorials/pandas-ta.git --no-cache-dir

Looking for a broker? Support the channel by using one of these links. We receive a commission on your trades from the broker at no cost to you:

For Australian customers without trading restrictions – Black Bull
Outside Australia – FXTrading

Scroll to Top