Python-MetaTrader 5 Strategy Tester (Part 06): MQL5-Style Backtesting for Python Expert Advisors
Contents
- Introduction
- Virtual MetaTrader 5, the shadow copy
- One variable, two trading environments
- Different data sources, same origin
- Backtesting using a standalone function
- A separate tester statistic object
- The complete MQL5-Like Python EA, backtesting & live trading
- Conclusion
Introduction
When you are developing trading robots for MetaTrader 5 in Python and face a familiar split: for live trading you call the MetaTrader5 package, while for backtesting you must build and maintain a separate tester layer with different initialization, data sources and APIs. The result is duplicated logic, brittle synchronization between two code paths, and wasted time every time you change your strategy. What we want instead is an MQL5-like workflow in Python: a single EA-style main() (OnTick) implementing trading logic once, and the ability to run that same code against historical data or in the live market without rewriting it.
This article shows how the StrategyTester5 framework achieves that: it provides a VirtualMetaTrader5 that mirrors the MetaTrader5 API, a simple mt5 variable swap to switch environments, and a run_backtesting() function that replays market data and returns a TesterStats object. The goal is practical: write your strategy once, then choose whether to run it on history or on a live account.
Take a look at this RSI mean reversion Python EA:
def main(rsi_window: int=14, rsi_oversold: float=30.0, rsi_overbought: float=70.0): rates = mt5.copy_rates_from_pos(symbol, timeframe, 0, rsi_window) if rates is None or len(rates) < rsi_window: # if no information was found, or fewer than expected rates were returned return # prevent further calculations rates_df = pd.DataFrame(data=rates) rsi_value = rsi(close=rates_df["close"], window=rsi_window).iloc[-1] # Rsi indicator calculation symbol_info = mt5.symbol_info(symbol) # symbol information lot_size = symbol_info.volume_min # rsi strategy if rsi_value < rsi_oversold: # long signal if not pos_exists(magic=MAGIC_NUMBER, pos_type=mt5.POSITION_TYPE_BUY): m_trade.buy(volume=lot_size, price=symbol_info.ask) # open a buy position # close opposite position position_close(magic=MAGIC_NUMBER, pos_type=mt5.POSITION_TYPE_SELL) if rsi_value > rsi_overbought: # short signal if not pos_exists(magic=MAGIC_NUMBER, pos_type=mt5.POSITION_TYPE_SELL): m_trade.sell(volume=lot_size, price=symbol_info.bid) # open a sell position # close opposite position position_close(magic=MAGIC_NUMBER, pos_type=mt5.POSITION_TYPE_BUY) if "--backtesting" in script_argument: #strategy testing stats = run_backtesting( main_function=main, tester_config=tester_config, virtual_mt5=mt5, logging_level=logging.DEBUG ) else: # run the script (bot) on the market (real-time) while True: main()
Notice how familiar everything looks to MQL5? Apart from a syntax that resembles the MQL5 programming language, the same function — main(), can run in both live trading and during a backtest, depending on what the user wants.
In the previous articles of this series, we laid the foundation for our backtesting framework by introducing the simulation engine, data storage capabilities, and methods similar to those provided by the MetaTrader 5 Python framework. This article, shows how the StrategyTester 5 framework we've been building in this article series achieves that. Firstly, install the framework in your active Python virtual environment by running one of the following commands.
With Pip (recommended):
pip install strategytester5==2.2.7
With Git:
git clone git@github.com:MegaJoctan/StrategyTester5.git strategytester5
Virtual MetaTrader 5, The Shadow Copy
In the second article of this series, we discussed overloading built-in functions provided by the MetaTrader 5-Python framework. The goal was to provide programmers with a familiar interface to that provided by the Python package. We implemented all methods including symbol_info_tick, copy_rates_from_pos, copy_rates_range, positions_get, etc.
This worked like a charm because as long as you were familiar with the MetaTrader 5-Python package, you just had to adapt your existing Python script to these familiar methods that came out of the class StrategyTester5 inside tester.py.
However, this introduces an extra layer of complexity: you must initialize the class and access methods through the tester object. Meaning you had to understand where the class is located, not to mention instantiate it with the right parameters.
This time we introduced a submodule called MetaTrader 5 in the framework, and at its core there is a module called api.py; inside it the class VirtualMetaTrader 5 resides:
class VirtualMetaTrader5(MetaTrader5Constants): """The simulated MetaTrader5 Instance similar to [https://pypi.org/project/metatrader5/](https://pypi.org/project/metatrader5/)""" def __init__(self, parent_mt5: MetaTrader5, custom_broker_data_path: str = "", ): """ Instantiates the simulated MetaTrader5 instance. Args: parent_mt5 (Any): MetaTrader5 API/client instance used for obtaining crucial information from the broker as an attempt to mimic the terminal. custom_broker_data_path (bool | optional): Where custom folders for history are kept. """ super().__init__() # store global variables #.... #.... def account_info(self) -> Optional[AccountInfo]: """Gets info on the current trading account. [Reference](https://www.mql5.com/en/docs/python_metatrader5/mt5accountinfo_py) Returns: Trading account's information in a namedtuple (tuple) called AccountInfo """ return self.ACCOUNT def symbol_info(self, symbol: str) -> Optional[SymbolInfo]: """Gets data on the specified financial instrument. [Reference](https://www.mql5.com/en/docs/python_metatrader5/mt5symbolinfo_py) Returns: Symbol's information in a namedtuple (tuple) called SymbolInfo """ if symbol not in self.SYMBOL_INFO_CACHE: self.warning_log(f"Failed to obtain symbol info for {symbol}") return None return self.SYMBOL_INFO_CACHE[symbol] def symbol_info_tick(self, symbol: str) -> Tick: """Gets the last tick for the specified financial instrument. [Reference](https://www.mql5.com/en/docs/python_metatrader5/mt5symbolinfotick_py) Returns: Tick: Returns the tick data as a named tuple Tick. Returns None in case of an error. The info on the error can be obtained using last_error(). """
This class receives the instantiated MetaTrader 5 object and obtains crucial details from it, including; symbol information, account information, terminal info, etc., and stores the information in its arrays (memory), creating a shadow copy of the terminal.
With very similar methods, using the VirtualMetaTrader 5 object feels like using the original MetaTrader 5 framework object.

The reason for having this copy is to use it during a simulations (backtesting) so that we don't have to request the most common information directly from the terminal, which would be computationally expensive and might cause issues as the terminal itself relies on the internet.
One Variable, Two Trading Environments
Unlike the approach discussed previously, where we had all methods implemented in a single class StrategyTester, having a separate module makes it straightforward and allows easy switching between backtesting (using VirtualMetaTrader 5) and live trading that uses the original MetaTrader 5-Framework.
from strategytester5.MetaTrader5.api import VirtualMetaTrader5 import MetaTrader5 as parent_mt5 if not parent_mt5.initialize(): raise RuntimeError(f"Failed to initialize mt5. Error = {parent_mt5.last_error()}") script_argument = sys.argv[1:] if "--backtesting" in script_argument: mt5 = VirtualMetaTrader5(parent_mt5=parent_mt5) # Assign parent MetaTrader5 to the virtual MetaTrader5 class object else: mt5 = parent_mt5
Thanks to Python's dynamic type assignment, the existence of the MetaTrader 5-object can be replaced with the VirtualMetaTrader 5 object.
The architecture looks like this:

To ensure all references of VirtualMetaTrader 5 work just like the MetaTrader 5 object, not only do we need similar functions in the class, but we also need the same variables and properties declared from within. That is why you can see the class VirtualMetaTrader 5 inheriting the parent class called MetaTrader 5 Constants, which contains all variables and constants similar to those offered by the MetaTrader 5-Python framework.
class VirtualMetaTrader5(MetaTrader5Constants): From strategytester5/MetaTrader5/constants.py.
from dataclasses import dataclass @dataclass class MetaTrader5Constants: """MetaTrader 5 constants, such as timeframes, order types, deal types, etc.""" # timeframes TIMEFRAME_M1 = 1 """One-minute timeframe.""" TIMEFRAME_M2 = 2 """Two-minute timeframe.""" TIMEFRAME_M3 = 3 #... #... #...
Different Data Sources, Same Origin
Since the very first version of the software, we have made a distinction between where we want to extract our trading data from. During simulation, we read data from .parquet files, and during live trading we obtain data from the terminal directly.
In the previous article, we saw that obtaining data directly from the terminal was quick and efficient compared to reading from .parquet files using polars DataFrame in the demanding multicurrency backtesting. So, to give users a chance to read the terminal during a simulation we introduced an optional variable parent_mt5_source.
When set to True, all methods for collecting historical data such as copy_rates* and copy_ticks*,read the information from the terminal instead of .parquet files.
def copy_rates_range(self, symbol: str, timeframe: int, date_from: datetime, date_to: datetime, parent_mt5_source: bool = False, polars_collect_engine: Literal["auto", "in-memory", "streaming", "gpu"] = "auto" ) -> Optional[ RATES_DTYPE]: """Get bars in the specified date range from the MetaTrader 5 terminal. [Reference](https://www.mql5.com/en/docs/python_metatrader5/mt5copyratesrange_py) Args: symbol (str): Financial instrument name, for example, "EURUSD". Required unnamed parameter. timeframe (int): Timeframe the bars are requested for. Set by a value from the TIMEFRAME enumeration. Required unnamed parameter. date_from (datetime): Date the bars are requested from. Set by the 'datetime' object or as a number of seconds elapsed since 1970.01.01. Bars with the open time >= date_from are returned. Required unnamed parameter. date_to (datetime): Date, up to which the bars are requested. Set by the 'datetime' object or as a number of seconds elapsed since 1970.01.01. Bars with the open time <= date_to are returned. Required unnamed parameter. parent_mt5_source (bool): Whether to obtain rates directly from the `parent_mt5` (directly from the terminal) when set to True. Or, from custom broker's path created by the StrategyTester object. polars_collect_engine (str): Engine used by Polars when collecting rates from custom broker's path. Supported values are: - ``"auto"`` (default): Use Polars’ standard in-memory engine and respect the ``POLARS_ENGINE_AFFINITY`` environment variable if set. - ``"in-memory"``: Explicitly use the default in-memory engine, optimized with multi-threading and SIMD over Arrow data. - ``"streaming"``: Process queries in batches, enabling larger-than-RAM datasets. - ``"gpu"``: Use NVIDIA GPUs via RAPIDS cuDF for accelerated execution. Requires installing Polars with GPU support, e.g.: ``pip install polars[gpu] --extra-index-url=https://pypi.nvidia.com``. Returns: Returns bars as the numpy array with the named time, open, high, low, close, tick_volume, spread and real_volume columns. Returns None in case of an error. The info on the error can be obtained using MetaTrader5.last_error(). Notes: - In some cases, copying data directly from the terminal becomes cheap compared to reading from parquet files that introduce `file IO` operations that are computationally expensive. In such cases, `parent_mt5_source` becomes handy. """ if not isinstance(date_from, datetime) or not isinstance(date_to, datetime): self.warning_log("Failed, both `date_from` and `date_to` must be datetime objects") return None if parent_mt5_source: rates = self.parent_mt5.copy_rates_range(symbol, timeframe, date_from, date_to) self._last_error = self.parent_mt5.last_error() else: rates = self.history_manager.copy_rates_range_from_parquet(symbol, timeframe, date_from, date_to, polars_collect_engine=polars_collect_engine, broker_data_dir=self.broker_data_path, logger=self.logger, verbosity=not self.IS_OPTIMIZATION_MODE ) if rates is None or len(rates) == 0: self.warning_log(f"no rates found on {symbol} from {date_from} bars: {date_to}") return None return rates
Simulation data is now collected and managed in a separate module, data.py, in the class called HistoryManager that takes two mandatory parameters:
- mt5_instance — the original MetaTrader 5 object to obtain data from.
- broker_data_path — the path where you want data obtained from the terminal to be stored.
from . import data class VirtualMetaTrader5(MetaTrader5Constants): """The simulated MetaTrader5 Instance similar to [https://pypi.org/project/metatrader5/](https://pypi.org/project/metatrader5/)""" def __init__(self, parent_mt5: MetaTrader5, custom_broker_data_path: str = "", ): """ Instantiates the simulated MetaTrader5 instance. Args: parent_mt5 (Any): MetaTrader5 API/client instance used for obtaining crucial information from the broker as an attempt to mimic the terminal. custom_broker_data_path (bool | optional): Where custom folders for history are kept. """ super().__init__() #... #... self.history_manager = data.HistoryManager(mt5_instance=parent_mt5, broker_data_path=self.broker_data_path)
The class has familiar methods like copy_rates_from*, and copy_ticks_from*; they read data from .parquet files.
Below is how the entire class looks:

Backtesting Using a Standalone Function
Previously, you had to call the StrategyTester class, then handle the simulation workflow; this made the framework complex and tedious from a user standpoint.
This time we wrap the class and everything inside a standalone function called run_backtesting().
def run_backtesting(main_function: Any, tester_config: dict, virtual_mt5: VirtualMetaTrader5, is_optimization_mode: bool = False, dashboard_host: str = "localhost", dashboard_port: int = 5000, dashboard_fps: int = 30, logging_level: int = logging.WARNING, logs_dir: Optional[str] = "Logs", trading_history_dir: Optional[str] = "TradesHistory", polars_collect_engine: Literal["auto", "in-memory", "streaming", "gpu"] = "auto" ) -> TesterStats: """ Runs the main_function (OnTick) through multiple ticks or bars in history, depending on the type of modelling specified in the tester_config dictionary. Args: tester_config (dict): Dictionary of tester configuration values. virtual_mt5 (MetaTrader5): Virtual (simulated) MetaTrader5 instance. logging_level: Minimum severity of messages to record. Uses standard `logging` levels (e.g., logging.DEBUG, INFO, WARNING, ERROR, CRITICAL). Messages below this level are ignored. logs_dir (str): Directory for log files. trading_history_dir (str | optional) A directory to keep trading history. main_function: A callback function that executes the strategy logic on each tick. This function is called after all ticks for a given timestamp are processed and the simulated MetaTrader5 instance is updated with the latest tick information. is_optimization_mode: When set to true, it runs the simulator and everything in "light-mode". It prevents logging, real-time dashboard, avoids unnecessary calculations. Just to end up with a quick and smooth backtest which suits multiple backtesting iterations (optimization). dashboard_host : The local server host dashboard_port : The local server port dashboard_fps: The interval to update the chart on a browser alongside the trades table with other active values such as balance, equity, etc. polars_collect_engine (str): Engine used by Polars when collecting historical data in functions for obtaining ticks — copy_ticks*, and bars information/rates (copy_rates*). Supported values are: - ``"auto"`` (default): Use Polars’ standard in-memory engine and respect the ``POLARS_ENGINE_AFFINITY`` environment variable if set. - ``"in-memory"``: Explicitly use the default in-memory engine, optimized with multi-threading and SIMD over Arrow data. - ``"streaming"``: Process queries in batches, enabling larger-than-RAM datasets. - ``"gpu"``: Use NVIDIA GPUs via RAPIDS cuDF for accelerated execution. Requires installing Polars with GPU support, e.g.: ``pip install polars[gpu] --extra-index-url=https://pypi.nvidia.com``. Raises: RuntimeError: If required MT5 account info cannot be obtained. Returns: TesterStats (optional): An object containing various statistics computed from the tester results, including trade performance metrics, drawdowns, and more. This is the same stats object that is used to generate the final HTML report. """ if not isinstance(virtual_mt5, VirtualMetaTrader5): raise RuntimeError("virtual_mt5 argument should have the virtualMetaTrader5 instance object.") cache_key = id(virtual_mt5) # one tester per VirtualMT5 instance if is_optimization_mode and cache_key in _optimization_cache: tester = _optimization_cache[cache_key] else: tester = StrategyTester( tester_config=tester_config, virtual_mt5=virtual_mt5, logging_level=logging_level, logs_dir=logs_dir, trading_history_dir=trading_history_dir, polars_collect_engine=polars_collect_engine ) if is_optimization_mode: _optimization_cache[cache_key] = tester return tester.run( on_tick_function=main_function, is_optimization_mode=is_optimization_mode, dashboard_host=dashboard_host, dashboard_port=dashboard_port, dashboard_fps=dashboard_fps, )
By the way, a lot has changed strategytester5 version 2.27, now available at pypi.org.
Starting with the introduction of the run() method, formally known as OnTick(). We initially named the main function for running backtesting on every tick of simulation data as OnTick() to resemble the MQL5 syntax, but I found the name confusing to Python-minded coders. Now, the function run() is the one responsible for running a simulation (strategy testing).
Also, there is a twist in how the simulation is run depending on the modelling type chosen by the user in tester configurations; this time, the simulation for each modelling type, such as every tick, open price only, and 1-Minute OHLC, is run in a separate function.
def _run_tick_simulation( self, df: pl.DataFrame, symbols: list[str], on_tick_function, dashboard_fps: int = 60 ): """ This function drives the strategy tester using grouped tick events. Args: df (pl.DataFrame): A Polars DataFrame containing tick data, with columns for time, symbol_id, bid, ask, etc. symbols (list[str]): A list mapping symbol_id to actual symbol names. on_tick_function: A callback function that executes the strategy logic on each tick. This function is called after all ticks for a given timestamp are processed and the simulated MetaTrader5 instance is updated with the latest tick information. dashboard_fps: The interval to update the chart on a browser alongside the trades table with other active values such as balance, equity, etc. """ total_rows = df.height processed = 0 grouped = df.group_by("time_msc", maintain_order=True) with tqdm(total=total_rows, desc="StrategyTester Progress", unit="tick") as pbar: for _, rows in grouped: n = rows.height # process all ticks at this timestamp for row in rows.iter_rows(named=True): symbol = symbols[row["symbol_id"]] self.simulated_mt5.tick_update(symbol, row) if self.IS_MARGIN_STOPOUT: break t = row["time"] self._monitor_mt5(current_time=t, dashboard_fps=dashboard_fps) # update progress AFTER processing group processed += n self.TESTER_IDX += 1 # increment tester progress pbar.update(n) # call strategy AFTER all symbols updated on_tick_function() def _run_bar_simulation( self, df: pl.DataFrame, symbols: list[str], on_tick_function, dashboard_fps: int = 60 ): """ This function drives the strategy tester using grouped bars. Args: df (pl.DataFrame): A Polars DataFrame containing bars data, with columns for time, open, high, low, close, etc. symbols (list[str]): A list mapping symbol_id to actual symbol names. on_tick_function: A callback function that executes the strategy logic on each tick. This function is called after all ticks for a given timestamp are processed and the simulated MetaTrader5 instance is updated with the latest tick information. dashboard_fps: The interval to update the chart on a browser alongside the trades table with other active values such as balance, equity, etc. """ total_rows = df.height processed = 0 grouped = df.group_by("time", maintain_order=True) with tqdm(total=total_rows, desc="StrategyTester Progress", unit="bar") as pbar: current_time = self.tester_config["start_date"] for _, rows in grouped: n = rows.height # process all ticks at this timestamp for row in rows.iter_rows(named=True): symbol = symbols[row["symbol_id"]] s_info = self.simulated_mt5.symbol_info(symbol) if s_info is None: self.critical_log("No symbol info found in the simulated (virtual) MetaTrader5 instance") continue current_time = row["time"] tick = Tick( time=current_time, bid=row["close"], ask=row["close"] + row["spread"] * s_info.point, last=0, volume=row["tick_volume"], time_msc=row["time"] * 1000, flags=-1, volume_real=0 ) self.simulated_mt5.tick_update(symbol, tick) if self.IS_MARGIN_STOPOUT: break self._monitor_mt5(current_time=current_time, dashboard_fps=dashboard_fps) # update progress AFTER processing group processed += n self.TESTER_IDX += 1 # increment tester progress pbar.update(n) # call strategy AFTER all symbols updated on_tick_function()The run() method is the entry point for backtesting. It prepares the tester, loads historical data, selects a modeling mode, runs the simulation, updates the dashboard, finalizes positions, computes statistics, and returns the results.
def run(self, on_tick_function: Any, is_optimization_mode: bool = False, dashboard_host: str = "localhost", dashboard_port: int = 5000, dashboard_fps: int = 60 ) -> Optional[stats.TesterStats]: """Main function to run the strategy tester simulation. It initializes the tester, processes historical data according to the specified modelling mode, and generates a report at the end. Args: on_tick_function: A callback function that executes the strategy logic on each tick. This function is called after all ticks for a given timestamp are processed and the simulated MetaTrader5 instance is updated with the latest tick information. is_optimization_mode: When set to true, it runs the simulator and everything in "light-mode". It prevents logging, real-time dashboard, avoids unnecessary calculations. Just to end up with a smooth backtest. dashboard_host : The local server host dashboard_port : The local server port dashboard_fps: The interval to update the chart on a browser alongside the trades table with other active values such as balance, equity, etc. Returns: TesterStats (optional): An object containing various statistics computed from the tester results, including trade performance metrics, drawdowns, and more. This is the same stats object that is used to generate the final HTML report. """ self.IS_OPTIMIZATION_MODE = is_optimization_mode # ---------------- START SERVER ---------------- def start_dashboard(): if not webbrowser.open(f"http://{dashboard_host}:{dashboard_port}"): self.warning_log("Failed to open browser for dashboard") socketio.run( app, host=dashboard_host, port=dashboard_port, debug=False, use_reloader=False, allow_unsafe_werkzeug=True ) if not is_optimization_mode: # run flask in background threading.Thread( target=start_dashboard, daemon=True, ).start() # time.sleep(1) start_date = self.tester_config["start_date"] end_date = self.tester_config["end_date"] symbols = self.tester_config["symbols"] modelling = self.tester_config["modelling"] timeframe = self.tester_config["timeframe"] # synchronize the data once during optimization if self.IS_OPTIMIZATION_FIRST_RUN: self.history_manager.synchronize_all_timeframes(symbols, start_date, end_date) if not self._tester_init(is_optimization_mode=self.IS_OPTIMIZATION_MODE): # initialize the tester self.error_log("Failed to initialize StrategyTester") return None if modelling == 4: # build tick stream if self.IS_OPTIMIZATION_FIRST_RUN: self.history_dataframe = self.history_manager.build_tick_stream( symbols, start_date, end_date, True, self.polars_collect_engine ) self._initialize_curves(tick_size=self.history_dataframe.height) # run simulation self._run_tick_simulation( self.history_dataframe, symbols, on_tick_function, dashboard_fps=dashboard_fps ) elif modelling in (2, 1): if not self._tester_init(is_optimization_mode=self.IS_OPTIMIZATION_MODE): # initialize the tester self.error_log("Failed to initialize StrategyTester") return if self.IS_OPTIMIZATION_FIRST_RUN: self.history_dataframe = self.history_manager.build_bar_stream( symbols, self.simulated_mt5.STRING2TIMEFRAME_MAP["M1"] if modelling == 1 else self.simulated_mt5.STRING2TIMEFRAME_MAP[timeframe], start_date, end_date, True, self.polars_collect_engine ) self._initialize_curves(tick_size=self.history_dataframe.height) # run bar simulation self._run_bar_simulation( self.history_dataframe, symbols, on_tick_function, dashboard_fps=dashboard_fps ) # ---------------------- END OF THE TEST --------------------- # terminate all open positions cmt = "Margin stopout" if self.IS_MARGIN_STOPOUT else "End of test" self.simulated_mt5._terminate_all_positions(comment=cmt) self._record_curve_point() tester_stats = self.generate_tester_stats() # Generate tester stats if not self.IS_OPTIMIZATION_MODE: # Save trading history (orders & deals) into separate CSV files self._save_trading_history(self.trading_history_dir) # introduce backtest report to the dashboard self.DASHBOARD_STATE.simulation_running = False tester_stats_dict = tester_stats.to_dict() if tester_stats else {} if tester_stats: self.DASHBOARD_STATE.tester_stats = tester_stats_dict self.DASHBOARD_STATE.entries_pl_plot = self.generate_entries_pl_plot_json( deals_df=pd.DataFrame(self.simulated_mt5.DEALS), ) holding = self.generate_holding_time_json( orders_df=pd.DataFrame(self.simulated_mt5.ORDERS_HISTORY), ) self.DASHBOARD_STATE.holding_plot = holding.get("plot", {}) self.DASHBOARD_STATE.holding_stats = holding.get("summary", {}) payload = { "tester_stats": tester_stats_dict, "holding_stats": self.DASHBOARD_STATE.holding_stats, "entries_plot": self.DASHBOARD_STATE.entries_pl_plot, "holding_plot": self.DASHBOARD_STATE.holding_plot } socketio.emit( "simulation_finished", payload ) time.sleep(1) else: self.IS_OPTIMIZATION_FIRST_RUN = False self.simulated_mt5.reset_state() # reset data in the simulated MetaTrader5 instance return tester_stats
Just like in the previous version, the method accepts the strategy callback on_tick_function, together with a few options controlling how the simulation should be executed.
Strategy Callback
The tester does not need to know whether the strategy is based on moving averages, price action, machine learning, indicators, or something else. It simply provides the strategy with a simulated MetaTrader 5 environment and calls the supplied function at the appropriate point during the simulation.
For example.
def main(rsi_window: int=14, rsi_oversold: float=30.0, rsi_overbought: float=70.0): rates = mt5.copy_rates_from_pos(symbol, timeframe, 0, rsi_window) #... #... # rsi strategy if rsi_value < rsi_oversold: # long signal if not pos_exists(magic=MAGIC_NUMBER, pos_type=mt5.POSITION_TYPE_BUY): m_trade.buy(volume=lot_size, price=symbol_info.ask) # open a buy position
The strategy inside the function main() is responsible for making trading decisions, while run() and the underlying simulation engine are responsible for replaying the market and providing the simulated trading environment.
Optimization Mode
The parameter is_optimization_mode determines whether the tester is being executed as a normal interactive backtest or as part of an optimization process.
A normal backtest may need to perform several operations that are useful to a human user but unnecessary during optimization. For example, displaying a live dashboard, logging extensive information, saving trading history, and calculating additional visualization data can all introduce overhead.
So, when optimization mode is enabled, the tester runs in a lighter configuration.
The idea is simple, during backtesting we prioritize visibility and analysis meanwhile, during optimization we prioritize execution speed.
Starting the Dashboard
One of the major differences between the newer version of the framework and the previous implementation is the addition of a live browser dashboard.
The dashboard is started only when the tester is not running in optimization mode.
if not is_optimization_mode: # run flask in background threading.Thread( target=start_dashboard, daemon=True, ).start()
The dashboard is served locally using Flask-SocketIO. It runs in a background daemon thread that allows the webserver to run alongside the backtest instead of blocking the simulation.
Preparing the Historical Data
Before simulation begins, all required historical data must be available, so during the first run of an optimization process, the framework synchronizes the required historical data.
if self.IS_OPTIMIZATION_FIRST_RUN: self.history_dataframe = self.history_manager.build_bar_stream( symbols, self.simulated_mt5.STRING2TIMEFRAME_MAP["M1"] if modelling == 1 else self.simulated_mt5.STRING2TIMEFRAME_MAP[timeframe], start_date, end_date, True, self.polars_collect_engine )
The flag IS_OPTIMIZATION_FIRST_RUN is crucial here.
Since optimization can involve running the same historical data periodically with just different strategy parameters, it would be wasteful to download, synchronize, and reconstruct the same historical dataset for every optimization iteration. So, during optimization we reuse the same data across all trials.
Notice that, the method run() returns an object called TesterStats?
A Separate Tester Statistics Object
Instead of keeping every calculation inside the class StrategyTester, we decided to have all the metrics and statistics in a separate module.
Inside strategytester5/stats.py, there is a class called TesterStats, all metrics similar to those offered by MetaTrader 5's strategy tester are calculated and stored in this class.

Below are some of the properties present in the class.
class TesterStats: """ Computes various statistics from the tester results, including trade performance metrics, drawdowns, and more. This class is responsible fo calculating all the stats you see in the HTML report""" def __init__(self, deals: list, initial_deposit: float, balance_curve: np.ndarray, equity_curve: np.ndarray, margin_level_curve: np.ndarray, ticks: int, symbols: int ): """ Initializes the TesterStats object with the provided data and computes all statistics. Args: deals (list): List of deal records from the tester. initial_deposit (float): The initial deposit amount used in the test. balance_curve (np.ndarray): Array representing the balance curve over time. equity_curve (np.ndarray): Array representing the equity curve over time. margin_level_curve (np.ndarray): Array representing the margin level curve over time. ticks (int): Total number of ticks processed during the test. symbols (int): Total number of unique symbols traded during the test. """ #... #... #... @property def initial_deposit(self): """The initial trading capital for the backtest""" return self.initial_deposit_ @property def ticks(self): """The number of ticks seen during the backtest""" return self.ticks_ @property def symbols(self): """The number of symbols seen during the backtest""" return self.symbols_ @property def total_trades(self) -> int: """ Total number of trades opened and closed during the test. """ return self._total_trades @property def total_deals(self) -> int: """The total number of deal records, including both entries and exits. Note that the first deal is usually the initial deposit and is not a real trade.""" return len(self.deals) - 1 @property def total_short_trades(self) -> int: """ Total number of short (SELL) trades closed during the test. """ return self._total_short_trades @property def total_long_trades(self) -> int: """ Total number of long (BUY) trades closed during the test. """ return self._total_long_trades @property def short_trades_won(self) -> int: """ Number of short (SELL) trades that were profitable (profit > 0) at closing. """ return self._short_trades_won @property def long_trades_won(self) -> int: """ Number of long (BUY) trades that were profitable (profit > 0) at closing. """ return self._long_trades_won @property def profit_trades(self) -> int: """ Number of trades that were profitable (profit > 0) at closing. """ return len(self._profits) if self._profits else 0 @property def loss_trades(self) -> int: """ Number of trades that were not profitable (profit <= 0) at closing. """ return len(self._losses) if self._losses else 0 @property def largest_profit_trade(self) -> float: """ Largest profit from a single trade. """ return np.max(self._profits) if self._profits else 0 @property def largest_loss_trade(self) -> float: """ Largest loss from a single trade. """ return np.min(self._losses) if self._losses else 0 @property def average_profit_trade(self) -> float: """ Average profit from profitable trades. """ return np.mean(self._profits) if self._profits else 0 @property def average_loss_trade(self) -> float: """ Average loss from unprofitable trades. """ return np.mean(self._losses) if self._losses else 0 # ---------- streak metrics ---------- @property def maximum_consecutive_wins_count(self) -> int: """ Maximum number of consecutive winning trades. """ return self._max_consec_win_count @property def maximum_consecutive_wins_money(self) -> float: """ Maximum money won from consecutive winning trades. """ return self._max_consec_win_money @property def maximum_consecutive_losses_count(self) -> int: """ Maximum number of consecutive losing trades. """ return self._max_consec_loss_count
When the simulation is complete, the above object is returned by the run method, and information from all its properties are sent to the opened browser page to create a similar tester report to that generated by MetaTrader 5's strategy tester.
Now, all this dates back to the method run_backtesting():
def run_backtesting(main_function: Any, tester_config: dict, virtual_mt5: VirtualMetaTrader5, #... #... ) -> TesterStats: return tester.run( on_tick_function=main_function, is_optimization_mode=is_optimization_mode, dashboard_host=dashboard_host, dashboard_port=dashboard_port, dashboard_fps=dashboard_fps, )
The Complete MQL5-Like Python EA, Backtesting & Live Trading
Finally, with all the changes and updates applied to the framework, below Is a complete MQL5-like trading robot in Python based on RSI oversold and overbought mean reversion trading strategy.
import logging from strategytester5.MetaTrader5.api import VirtualMetaTrader5 from strategytester5.tester import run_backtesting from strategytester5.trade_classes.Trade import CTrade import MetaTrader5 as parent_mt5 import pandas as pd from ta.momentum import rsi import sys if not parent_mt5.initialize(): raise RuntimeError(f"Failed to initialize mt5. Error = {parent_mt5.last_error()}") script_argument = sys.argv[1:] if "--backtesting" in script_argument: mt5 = VirtualMetaTrader5(parent_mt5=parent_mt5) # Assign parent MetaTrader5 to the virtual MetaTrader5 class object else: mt5 = parent_mt5 # ---------------------- inputs/global variables ---------------------------- symbol = "EURUSD" timeframe = mt5.TIMEFRAME_H1 # This should be an integer, so you should convert timeframe in string into integer # --------------------------------------------------------- MAGIC_NUMBER = 1001 m_trade = CTrade(terminal=mt5, symbol=symbol, magic_number=MAGIC_NUMBER, deviation_points=100) def pos_exists(magic: int, pos_type: int) -> bool: """Check if position exists""" positions_found = mt5.positions_get() for position in positions_found: if position.type == pos_type and position.magic == magic: return True return False def position_close(magic: int, pos_type: int): """Close positions by type""" positions_found = mt5.positions_get() for position in positions_found: if position.type == pos_type and position.magic == magic: m_trade.position_close(position.ticket) def main(rsi_window: int=14, rsi_oversold: float=30.0, rsi_overbought: float=70.0): rates = mt5.copy_rates_from_pos(symbol, timeframe, 0, rsi_window) if rates is None or len(rates) < rsi_window: # if no information was found, or less than expected rates were returned return # prevent further calculations rates_df = pd.DataFrame(data=rates) rsi_value = rsi(close=rates_df["close"], window=rsi_window).iloc[-1] # Rsi indicator calculation symbol_info = mt5.symbol_info(symbol) # symbol information lot_size = symbol_info.volume_min # rsi strategy if rsi_value < rsi_oversold: # long signal if not pos_exists(magic=MAGIC_NUMBER, pos_type=mt5.POSITION_TYPE_BUY): m_trade.buy(volume=lot_size, price=symbol_info.ask) # open a buy position # close opposite position position_close(magic=MAGIC_NUMBER, pos_type=mt5.POSITION_TYPE_SELL) if rsi_value > rsi_overbought: # short signal if not pos_exists(magic=MAGIC_NUMBER, pos_type=mt5.POSITION_TYPE_SELL): m_trade.sell(volume=lot_size, price=symbol_info.bid) # open a sell position # close opposite position position_close(magic=MAGIC_NUMBER, pos_type=mt5.POSITION_TYPE_BUY) tester_config = { "bot_name": "RSI Strategy Bot", "symbols": ["EURUSD"], "timeframe": "H1", "start_date": "01.01.2026", "end_date": "01.06.2026", "modelling" : "open price only", "deposit": 1000, "leverage": "1:100" } if "--backtesting" in script_argument: # strategy testing stats = run_backtesting( main_function=main, tester_config=tester_config, virtual_mt5=mt5, logging_level=logging.DEBUG ) else: # run the script (bot) on the market (realtime) while True: main()
Running a backtest:
(.venv) D:\StrategyTester5\examples\rsi trading bot>python bot.py --backtesting
Realtime trading:
(.venv) D:\StrategyTester5\examples\rsi trading bot>python bot.py
Below is the outcome when the script was run in backtesting mode:

Backtesting Report:

Despite the fancy-looking GUI, the backtest report is very similar to that offered by the MetaTrader 5 platform, helping you read the metrics you are already familiar with.
In the meantime, the command prompt displays the backtesting progress bar and the verbosity of trades happening during a simulation:
StrategyTester Progress: 0%| | 0/2548 [00:00<?, ?bar/s]127.0.0.1 - - [08/Aug/2026 07:12:24] "POST /socket.io/?EIO=4&transport=polling&t=P_Vad2N&sid=RdFKr6CDw1aglSxWAAAA HTTP/1.1" 200 - 2025-12-31 21:00:00 | WARNING | RSI Strategy Bot | [data.py:28 - _warning_log() ] => ICMarketsSC-Demo\EURUSD\Bars\H1\202512.parquet not found, skipping 2025-12-31 22:00:00 | WARNING | RSI Strategy Bot | [data.py:28 - _warning_log() ] => ICMarketsSC-Demo\EURUSD\Bars\H1\202512.parquet not found, skipping 2025-12-31 23:00:00 | WARNING | RSI Strategy Bot | [data.py:28 - _warning_log() ] => ICMarketsSC-Demo\EURUSD\Bars\H1\202512.parquet not found, skipping 2026-01-02 11:00:00 | INFO | RSI Strategy Bot | [api.py:1698 - order_send() ] => Position 3 opened successfully! StrategyTester Progress: 1%|█▎ | 20/2548 [00:00<00:12, 198.95bar/s]127.0.0.1 - - [08/Aug/2026 07:12:24] "GET /socket.io/?EIO=4&transport=polling&t=P_Vad2O&sid=RdFKr6CDw1aglSxWAAAA HTTP/1.1" 200 - 127.0.0.1 - - [08/Aug/2026 07:12:24] "GET /socket.io/?EIO=4&transport=polling&t=P_Vad7V&sid=RdFKr6CDw1aglSxWAAAA HTTP/1.1" 200 - StrategyTester Progress: 2%|██▌ | 40/2548 [00:00<00:15, 160.45bar/s]2026-01-05 18:00:00 | INFO | RSI Strategy Bot | [api.py:1698 - order_send() ] => Position 4 opened successfully! 2026-01-05 18:00:00 | INFO | RSI Strategy Bot | [api.py:1696 - order_send() ] => Position 5 closed successfully! StrategyTester Progress: 2%|███▌ | 57/2548 [00:00<00:16, 153.75bar/s]2026-01-06 17:00:00 | INFO | RSI Strategy Bot | [api.py:1698 - order_send() ] => Position 6 opened successfully! 2026-01-06 17:00:00 | INFO | RSI Strategy Bot | [api.py:1696 - order_send() ] => Position 7 closed successfully! StrategyTester Progress: 6%|█████████ | 145/2548 [00:00<00:11, 208.35bar/s]2026-01-12 07:00:00 | INFO | RSI Strategy Bot | [api.py:1698 - order_send() ] => Position 8 opened successfully! 2026-01-12 07:00:00 | INFO | RSI Strategy Bot | [api.py:1696 - order_send() ] => Position 9 closed successfully! StrategyTester Progress: 7%|██████████▍ | 167/2548 [00:00<00:11, 211.59bar/s]2026-01-13 03:00:00 | INFO | RSI Strategy Bot | [api.py:1698 - order_send() ] => Position 10 opened successfully! 2026-01-13 03:00:00 | INFO | RSI Strategy Bot | [api.py:1696 - order_send() ] => Position 11 closed successfully! StrategyTester Progress: 8%|█████████████▍ | 213/2548 [00:01<00:11, 210.50bar/s]127.0.0.1 - - [08/Aug/2026 07:12:25] "GET /socket.io/?EIO=4&transport=polling&t=P_VadJH HTTP/1.1" 200 - 127.0.0.1 - - [08/Aug/2026 07:12:25] "GET /socket.io/?EIO=4&transport=polling&t=P_VadJI HTTP/1.1" 200 - StrategyTester Progress: 9%|██████████████▉ | 238/2548 [00:01<00:10, 219.79bar/s]2026-01-16 15:00:00 | INFO | RSI Strategy Bot | [api.py:1698 - order_send() ] => Position 12 opened successfully! 2026-01-16 15:00:00 | INFO | RSI Strategy Bot | [api.py:1696 - order_send() ] => Position 13 closed successfully! StrategyTester Progress: 10%|████████████████▍ | 261/2548 [00:01<00:12, 187.70bar/s]2026-01-16 17:00:00 | INFO | RSI Strategy Bot | [api.py:1698 - order_send() ] => Position 14 opened successfully! 2026-01-16 17:00:00 | INFO | RSI Strategy Bot | [api.py:1696 - order_send() ] => Position 15 closed successfully! 127.0.0.1 - - [08/Aug/2026 07:12:25] "POST /socket.io/?EIO=4&transport=polling&t=P_VadOJ&sid=qV1wV6ff0mWb1lfcAAAC HTTP/1.1" 200 - 127.0.0.1 - - [08/Aug/2026 07:12:25] "POST /socket.io/?EIO=4&transport=polling&t=P_VadOK&sid=oBUVldpFQY-afwaiAAAD HTTP/1.1" 200 - 127.0.0.1 - - [08/Aug/2026 07:12:25] "GET /socket.io/?EIO=4&transport=polling&t=P_VadOL&sid=qV1wV6ff0mWb1lfcAAAC HTTP/1.1" 200 - 127.0.0.1 - - [08/Aug/2026 07:12:25] "GET /socket.io/?EIO=4&transport=polling&t=P_VadOP&sid=oBUVldpFQY-afwaiAAAD HTTP/1.1" 200 - 127.0.0.1 - - [08/Aug/2026 07:12:25] "GET /socket.io/?EIO=4&transport=polling&t=P_VadTR&sid=qV1wV6ff0mWb1lfcAAAC HTTP/1.1" 200 - StrategyTester Progress: 11%|█████████████████▋ | 281/2548 [00:01<00:14, 154.73bar/s]2026-01-19 13:00:00 | INFO | RSI Strategy Bot | [api.py:1698 - order_send() ] => Position 16 opened successfully! 2026-01-19 13:00:00 | INFO | RSI Strategy Bot | [api.py:1696 - order_send() ] => Position 17 closed successfully! 127.0.0.1 - - [08/Aug/2026 07:12:26] "GET /socket.io/?EIO=4&transport=polling&t=P_VadTT&sid=oBUVldpFQY-afwaiAAAD HTTP/1.1" 200 - StrategyTester Progress: 13%|████████████████████▊ | 332/2548 [00:02<00:20, 109.91bar/s]2026-01-22 01:00:00 | INFO | RSI Strategy Bot | [api.py:1698 - order_send() ] => Position 18 opened successfully! 2026-01-22 01:00:00 | INFO | RSI Strategy Bot | [api.py:1696 - order_send() ] => Position 19 closed successfully! StrategyTester Progress: 14%|██████████████████████ | 351/2548 [00:02<00:17, 125.40bar/s]2026-01-22 13:00:00 | INFO | RSI Strategy Bot | [api.py:1698 - order_send() ] => Position 20 opened successfully! 2026-01-22 13:00:00 | INFO | RSI Strategy Bot | [api.py:1696 - order_send() ] => Position 21 closed successfully! StrategyTester Progress: 15%|███████████████████████▎ | 371/2548 [00:02<00:15, 141.07bar/s]2026-01-23 12:00:00 | INFO | RSI Strategy Bot | [api.py:1698 - order_send() ] => Position 22 opened successfully! 2026-01-23 12:00:00 | INFO | RSI Strategy Bot | [api.py:1696 - order_send() ] => Position 23 closed successfully! 2026-01-23 18:00:00 | INFO | RSI Strategy Bot | [api.py:1698 - order_send() ] => Position 24 opened successfully! 2026-01-23 18:00:00 | INFO | RSI Strategy Bot | [api.py:1696 - order_send() ] => Position 25 closed successfully! StrategyTester Progress: 17%|███████████████████████████▏ | 432/2548 [00:02<00:11, 177.62bar/s]2026-01-28 11:00:00 | INFO | RSI Strategy Bot | [api.py:1698 - order_send() ] => Position 26 opened successfully! 2026-01-28 11:00:00 | INFO | RSI Strategy Bot | [api.py:1696 - order_send() ] => Position 27 closed successfully! #... #... #... StrategyTester Progress: 80%|██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████▋ | 2031/2548 [00:09<00:02, 238.69bar/s]2026-04-30 13:00:00 | INFO | RSI Strategy Bot | [api.py:1698 - order_send() ] => Position 116 opened successfully! 2026-04-30 13:00:00 | INFO | RSI Strategy Bot | [api.py:1696 - order_send() ] => Position 117 closed successfully! StrategyTester Progress: 82%|█████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████▉ | 2082/2548 [00:09<00:01, 244.24bar/s]2026-05-04 13:00:00 | INFO | RSI Strategy Bot | [api.py:1698 - order_send() ] => Position 118 opened successfully! 2026-05-04 13:00:00 | INFO | RSI Strategy Bot | [api.py:1696 - order_send() ] => Position 119 closed successfully! StrategyTester Progress: 83%|███████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████▍ | 2107/2548 [00:09<00:01, 243.41bar/s]2026-05-05 16:00:00 | INFO | RSI Strategy Bot | [api.py:1698 - order_send() ] => Position 120 opened successfully! 2026-05-05 16:00:00 | INFO | RSI Strategy Bot | [api.py:1696 - order_send() ] => Position 121 closed successfully! StrategyTester Progress: 84%|█████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ | 2132/2548 [00:09<00:01, 243.02bar/s]2026-05-07 02:00:00 | INFO | RSI Strategy Bot | [api.py:1698 - order_send() ] => Position 122 opened successfully! 2026-05-07 02:00:00 | INFO | RSI Strategy Bot | [api.py:1696 - order_send() ] => Position 123 closed successfully! 2026-05-07 11:00:00 | INFO | RSI Strategy Bot | [api.py:1698 - order_send() ] => Position 124 opened successfully! 2026-05-07 11:00:00 | INFO | RSI Strategy Bot | [api.py:1696 - order_send() ] => Position 125 closed successfully!
Final Thoughts
After the updates described in this article, you get a compact, practical workflow that brings Python trading closer to the “one EA, two environments” model:
- A VirtualMetaTrader 5 object that shadows the real MetaTrader 5 API (same methods, constants and properties).
- A single mt5 assignment that toggles between live and simulated environments (mt5 = MetaTrader 5 or mt5 = VirtualMetaTrader 5).
- A run backtesting(main function, tester config, virtual mt5, …) entry point that runs your main() over historical ticks/bars, launches an optional dashboard, supports an optimization (light) mode, and returns a TesterStats object containing standard strategy metrics.
- Flexible data sources: parquet-based history or direct terminal reads via parent mt5 source when file I/O would be a bottleneck.
Practically, this means you can keep one main() for both live trading and backtesting. For ML workflows, continue to use Python for model development and, if desired, export models to ONNX for native MQL5 deployment — combining Python's ecosystem with efficient execution in the MetaTrader environment.
Use the framework when you need a lightweight, Python-native way to validate strategies against MetaTrader 5-style APIs.
Contributions and feedback are welcome on GitHub: https://github.com/MegaJoctan/StrategyTester5.
Attachments Table
| Filename | Description & Usage |
|---|---|
| examples\rsi trading bot\bot.py | A trading robot script based on the RSI reversal strategy, written in Python for the MetaTrader 5 terminal. |
Warning: All rights to these materials are reserved by MetaQuotes Ltd. Copying or reprinting of these materials in whole or in part is prohibited.
This article was written by a user of the site and reflects their personal views. MetaQuotes Ltd is not responsible for the accuracy of the information presented, nor for any consequences resulting from the use of the solutions, strategies or recommendations described.
Features of Custom Indicators Creation
Building an Adaptive Fibonacci Volatility Band Indicator in MQL5
Features of Experts Advisors
Building AI-Powered Trading Systems in MQL5 (Part 10): A Resolution-Independent Vector Icon System
- Free trading apps
- Over 8,000 signals for copying
- Economic news for exploring financial markets
You agree to website policy and terms of use