preview
Python-MetaTrader 5 Strategy Tester (Part 06): MQL5-Style Backtesting for Python Expert Advisors

Python-MetaTrader 5 Strategy Tester (Part 06): MQL5-Style Backtesting for Python Expert Advisors

MetaTrader 5Tester |
110 0
Omega J Msigwa
Omega J Msigwa

Contents


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:

python metatrader 5

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:

  1. mt5_instance — the original MetaTrader 5 object to obtain data from.
  2. 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:

history manager class overview


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:

visual mode strategytester5

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.

Best regards.


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.
Attached files |
examples.zip (1.82 KB)
Features of Custom Indicators Creation Features of Custom Indicators Creation
Creation of Custom Indicators in the MetaTrader trading system has a number of features.
Building an Adaptive Fibonacci Volatility Band Indicator in MQL5 Building an Adaptive Fibonacci Volatility Band Indicator in MQL5
We build an adaptive Fibonacci volatility band indicator in MQL5 that centers on a smoothed price (SMMA) and scales band width with a smoothed ATR. The article covers inputs, buffer mapping, ATR handling, and SMMA formulas, then projects configurable Fibonacci ratios with filled zones. Readers get a ready workflow for visualizing volatility expansion/contraction and outlining dynamic support and resistance.
Features of Experts Advisors Features of Experts Advisors
Creation of expert advisors in the MetaTrader trading system has a number of features.
Building AI-Powered Trading Systems in MQL5 (Part 10): A Resolution-Independent Vector Icon System Building AI-Powered Trading Systems in MQL5 (Part 10): A Resolution-Independent Vector Icon System
We replace the embedded bitmap icons in our MQL5 canvas interface with a resolution-independent vector icon system. A small set of anti-aliased primitives (strokes, discs, rings, rounded rectangles, and polygons) draws every logo and sidebar glyph procedurally, then plugs into the header, sidebar, and theme toggle. You get smaller builds, theme-aware recoloring, and icons that stay sharp at any size.