Тестер стратегий для Python и MetaTrader 5 (Часть 04): Основы работы тестера
Оглавление
- Введение
- От симулятора к тестеру
- Настройка и инициализация тестера
- Тестирование стратегии на основе РЕАЛЬНЫХ ТИКОВ
- Тестирование стратегии на основе СИМУЛИРОВАННЫХ ТИКОВ
- Тестирование стратегии на основе НОВОГО БАРА
- Тестирование стратегии на 1-минутных OHLC
- Объединяем всё внутри функции OnTick
- Наконец, несколько торговых действий в тестере стратегий
- Создание отчётов тестера стратегий
- Заключение
Введение
В предыдущих статьях этой серии мы заложили основу для создания тестера стратегий, похожего на MetaTrader 5, с нуля. Хотя базовая структура уже готова, в нашем проекте всё ещё отсутствует несколько важных компонентов.
На данном этапе мы ещё не реализовали последовательную обработку тиков и баров, у нас нет механизмов для отслеживания открытых ордеров и смоделированного торгового счёта, а также отсутствуют такие показатели эффективности, как прибыль и убыток, просадка, процент прибыльных сделок, соотношение риска и прибыли и подробная торговая статистика в симуляторе.

Цель этой статьи — устранить эти пробелы и дополнительно улучшить наш проект.
От симулятора к тестеру
Если вы обратили внимание на класс, с которым мы работали в предыдущих публикациях, мы называли его Simulator. Такое название было выбрано для того, чтобы дать всем пользователям простое и понятное всем название. Тестер стратегий MetaTrader 5 по сути представляет собой симулятор, поэтому теперь мы переименуем наш класс в Tester.
class Tester: def __init__(self, simulator_name: str, mt5_instance: mt5, deposit: float, leverage: str="1:100"):
Это изменение также сопровождается изменением структуры папок для журналов.
class Tester: def __init__(self, simulator_name: str, mt5_instance: mt5, deposit: float, leverage: str="1:100"): """MetaTrader 5-Like Strategy tester for the MetaTrader5-Python module. Args: simulator_name (str): A Bot or Simulator's name mt5_instance (mt5): An instance of the Initialized MetaTrader 5 module deposit (float): The initial account balance for the Tester leverage (_type_, optional): A leverage of the simulated account. Defaults to "1:100". Raises: RuntimeError: When one of the operation fails """ self.mt5_instance = mt5_instance self.simulator_name = simulator_name config.mt5_logger = config.get_logger(self.simulator_name+".mt5", logfile=os.path.join(config.MT5_LOGS_DIR, f"{config.LOG_DATE}.log"), level=config.logging_level) config.tester_logger = config.get_logger(self.simulator_name+".tester", logfile=os.path.join(config.TESTER_LOGS_DIR, f"{config.LOG_DATE}.log"), level=config.logging_level)
Вывод

Настройка и инициализация тестера
Чтобы сразу понять, что необходимо для нашего пользовательского тестера стратегий, нужно взглянуть на настройки тестера стратегий MetaTrader 5.

| Поле | Описание и использование |
|---|---|
| Expert | Название советника Expert Advisor, EA, то есть торгового робота, которого вы хотите протестировать. |
| symbol | Символ графика, к которому прикреплён EA. Даже если ваш советник торгует несколькими символами, этот символ всё равно требуется как "основной" график. |
| Timeframe — поле сразу после symbol | Таймфрейм графика, используемый EA. Таймфрейм определяет поведение OnTick(), OnTimer() и событий баров. |
| Date, starting date — первое поле | Первая историческая дата, используемая в тесте. |
| Date, end date — второе поле | Последняя историческая дата, используемая в тесте. |
| Forward | Период форвардного тестирования после периода тестирования или оптимизации. |
| Delays | Имитация задержки исполнения. Имеет два варианта:
|
| Режим моделирования | Определяет, как генерируются тики во время теста. Поддерживаемые режимы моделирования включают:
|
| Deposit | Начальный баланс счёта. |
| leverage | Кредитное плечо счёта, используемое в тесте. |
| optimization | Режим оптимизации параметров. Если он отключён, выполняется один тест; если включён, тестер запускает EA несколько раз с разными комбинациями параметров. |
| visual mode... | Отображает анимацию графика во время тестирования. |
Нам нужно передать похожие настройки в наш класс Tester; для нашего консольного Python-проекта удобно использовать JSON-файл.
configs/tester.json
{
"tester": {
"bot_name": "MY EA",
"symbols": ["EURUSD", "USDCAD", "USDJPY"],
"timeframe": "H1",
"start_date": "01.01.2025 00:00",
"end_date": "31.12.2025 00:00",
"modelling" : "real_ticks",
"deposit": 1000,
"leverage": "1:100"
}
} Пока нам нужно всего несколько параметров. По мере дальнейшего погружения в проект всё ещё остаётся пространство для улучшений.
Введение JSON-файла для конфигураций делает часть аргументов, которые были в предыдущей версии этого класса, устаревшими. Ниже приведён новый конструктор класса.
class Tester: def __init__(self, tester_config: dict, mt5_instance: mt5): """MetaTrader 5-Like Strategy tester for the MetaTrader5-Python module. Args: configs_json (dict): a dictonary containing tester configurations Raises: RuntimeError: When one of the operation fails """
В MetaTrader 5 нам нужно указать только основной символ; платформа сама автоматически обрабатывает импорты и тики по всем торговым инструментам, задействованным в программе.
Не совсем понятно, как терминал может это делать, но MQL5 — компилируемый язык, в отличие от Python; нам может быть непросто это повторить. Пока пользователю нужно указывать все торговые инструменты, которые будут использоваться во время выполнения программы.
"symbols": ["EURUSD", "USDCAD", "USDJPY"],
Однако использование JSON-файла для параметров повышает вероятность ошибок; даже небольшой опечатки достаточно, чтобы нарушить работу программы.
С учётом этого нам нужны функции для проверки информации, полученной из такого объекта.
I. Проверяем наличие корректных ключей JSON
Функция ниже вызывает ошибки выполнения, если в словаре отсутствует обязательный параметр или присутствует неизвестный, дополнительный параметр.
Внутри validators.py:
class TesterConfigValidators: """ Responsible for validating and normalizing strategy tester configurations. """ def __init__(self): pass @staticmethod def _validate_keys(raw_config: Dict) -> None: required_keys = { "bot_name", "symbols", "timeframe", "start_date", "end_date", "modelling", "deposit", "leverage", } provided_keys = set(raw_config.keys()) missing = required_keys - provided_keys if missing: raise RuntimeError(f"Missing tester config keys: {missing}") extra = provided_keys - required_keys if extra: raise RuntimeError(f"Unknown tester config keys: {extra}")
Каждый раз, когда мы вносим изменения, добавляя новый параметр в наш JSON-файл под родительским ключом "tester", нам также нужно обновлять словарь с именем required_keys.
II. Проверяем, что всем ключам соответствуют корректные значения
Нам необходимо убедиться, что для каждой записи указан правильный тип данных.
validators.py
@staticmethod def parse_tester_configs(raw_config: Dict) -> Dict: TesterConfigValidators._validate_keys(raw_config) cfg: Dict = {} # --- BOT NAME --- cfg["bot_name"] = str(raw_config["bot_name"]) # --- SYMBOLS --- symbols = raw_config["symbols"] if not isinstance(symbols, list) or not symbols: raise RuntimeError("symbols must be a non-empty list") cfg["symbols"] = symbols # --- TIMEFRAME --- timeframe = raw_config["timeframe"].upper() if timeframe not in utils.TIMEFRAMES: raise RuntimeError(f"Invalid timeframe: {timeframe}") cfg["timeframe"] = timeframe # --- MODELLING --- modelling = raw_config["modelling"].lower() VALID_MODELLING = {"real_ticks", "new_bar"} if modelling not in VALID_MODELLING: raise RuntimeError(f"Invalid modelling mode: {modelling}") cfg["modelling"] = modelling # --- DATE PARSING --- try: start_date = datetime.strptime( raw_config["start_date"], "%d.%m.%Y %H:%M" ) end_date = datetime.strptime( raw_config["end_date"], "%d.%m.%Y %H:%M" ) except ValueError: raise RuntimeError("Date format must be: DD.MM.YYYY HH:MM") if start_date >= end_date: raise RuntimeError("start_date must be earlier than end_date") cfg["start_date"] = start_date cfg["end_date"] = end_date # --- DEPOSIT --- deposit = float(raw_config["deposit"]) if deposit <= 0: raise RuntimeError("deposit must be > 0") cfg["deposit"] = deposit # --- LEVERAGE --- cfg["leverage"] = TesterConfigValidators._parse_leverage(raw_config["leverage"]) return cfg
Именно внутри конструктора класса мы проверяем словарь, полученный из JSON-файла, прежде чем присвоить результирующий словарь переменной, доступной всему классу.
self.tester_config = TesterConfigValidators.parse_tester_configs(tester_config)
Теперь, когда мы можем получать информацию из JSON-файла, давайте посмотрим, как можно обрабатывать разные режимы моделирования при тестировании наших торговых роботов.
Тестирование стратегии на основе РЕАЛЬНЫХ ТИКОВ
Из всех режимов моделирования цен, доступных в тестере стратегий MetaTrader 5, этот является самым точным. Он предполагает тестирование программы, индикатора или советника, на тиках, полученных непосредственно от брокера.
Реализовать это довольно просто, учитывая, что мы уже добавили функции для получения тиков и баров за указанный исторический период.
tester.py
from src import ticks
class Tester: def __init__(self, tester_config: dict, mt5_instance: mt5): # ... self.__GetLogger().info("Tester Initializing") self.__GetLogger().info(f"Tester configs: {self.tester_config}") self.TESTER_ALL_TICKS_INFO = [] # for storing all ticks to be used during the test self.TESTER_ALL_BARS_INFO = [] # for storing all bars to be used during the test start_dt = self.tester_config["start_date"] end_dt = self.tester_config["end_date"] modelling = self.tester_config["modelling"] for symbol in self.tester_config["symbols"]: if modelling == "real_ticks": ticks_obtained = ticks.fetch_historical_ticks(start_datetime=start_dt, end_datetime=end_dt, symbol=symbol) ticks_info = { "symbol": symbol, "ticks": ticks_obtained, "size": ticks_obtained.height, "counter": 0 } self.TESTER_ALL_TICKS_INFO.append(ticks_info)
После получения тиков по всем указанным торговым инструментам мы добавляем результат в массив с именем TESTER_ALL_TICKS_INFO. Именно по нему мы позже будем проходить в главном цикле симуляции.
В функции OnTick этого класса, в этой функции мы объединяем всё вместе.
def OnTick(self, ontick_func): """Calls the assigned function upon the receival of new tick(s) Args: ontick_func (_type_): A function to be called on every tick """ if not self.IS_TESTER: return modelling = self.tester_config["modelling"] if modelling == "real_ticks": self.__GetLogger().debug(f"total number of ticks: {total_ticks}") while True: any_tick_processed = False for ticks_info in self.TESTER_ALL_TICKS_INFO: symbol = ticks_info["symbol"] size = ticks_info["size"] counter = ticks_info["counter"] if counter >= size: continue current_tick = ticks_info["ticks"].row(counter) self.TickUpdate(symbol=symbol, tick=current_tick) ontick_func() ticks_info["counter"] = counter + 1 any_tick_processed = True if not any_tick_processed: break
В подобных ситуациях удобно использовать индикатор выполнения:
def OnTick(self, ontick_func): """Calls the assigned function upon the receival of new tick(s) Args: ontick_func (_type_): A function to be called on every tick """ if not self.IS_TESTER: return modelling = self.tester_config["modelling"] if modelling == "real_ticks" or modelling == "every_tick": total_ticks = sum(ticks_info["size"] for ticks_info in self.TESTER_ALL_TICKS_INFO) self.__GetLogger().debug(f"total number of ticks: {total_ticks}") with tqdm(total=total_ticks, desc="Tester Progress", unit="tick") as pbar: while True: any_tick_processed = False for ticks_info in self.TESTER_ALL_TICKS_INFO: symbol = ticks_info["symbol"] size = ticks_info["size"] counter = ticks_info["counter"] if counter >= size: continue current_tick = ticks_info["ticks"].row(counter) self.TickUpdate(symbol=symbol, tick=current_tick) ontick_func() ticks_info["counter"] = counter + 1 any_tick_processed = True pbar.update(1) if not any_tick_processed: break
Приведённый выше OnTick метод принимает функцию, которая выступает в роли основной торговой логики, подобно функции OnTick в MQL5.
Функция, полученная через аргумент ontick_func, будет вызываться внутри метода OnTick на каждой итерации тика в главном цикле симуляции.
Пример использования.
tester.py
if __name__ == "__main__": mt5.initialize() try: with open(os.path.join('configs/tester.json'), 'r', encoding='utf-8') as file: # reading a JSON file # Deserialize the file data into a Python object data = json.load(file) except Exception as e: raise RuntimeError(e) sim = Tester(tester_config=data["tester"], mt5_instance=mt5) def ontick_function(): pass # print("some trading actions") sim.OnTick(ontick_function)
Вывод

Тестирование стратегии на основе СИМУЛИРОВАННЫХ ТИКОВ
Когда в тестере стратегий MetaTrader 5 режим моделирования установлен на Every tick, терминал использует синтетические тики. Они генерируются с помощью определённого алгоритма, который обсуждается в этой статье.
Попытку имитировать этот алгоритм можно найти в классе, расположенном внутри src/ticks_gen.py.
На этот раз вместо чтения тиков из Polars DataFrame мы генерируем тики на основе одноминутных баров.
Inside tester.py
from src.ticks_gen import TicksGen
elif modelling == "every_tick": bars_df = bars.fetch_historical_bars(symbol=symbol, timeframe=utils.TIMEFRAMES["M1"], start_datetime=start_dt, end_datetime=end_dt) ticks_obtained = TicksGen.generate_ticks_from_bars(bars=bars_df, symbol=symbol, symbol_point=self.symbol_info(symbol).point, out_dir=f"{config.SIMULATED_TICKS_DIR}/{symbol}", return_df=True) ticks_info = { "symbol": symbol, "ticks": ticks_obtained, "size": ticks_obtained.height, "counter": 0 } self.TESTER_ALL_TICKS_INFO.append(ticks_info)
Единственное различие между реальными и сгенерированными тиками заключается в том, что одни создаются алгоритмически, а другие извлекаются из базы данных; во всём остальном они одинаковы, поэтому внутри метода OnTick мы обрабатываем их одинаково.
def OnTick(self, ontick_func): """Calls the assigned function upon the receival of new tick(s) Args: ontick_func (_type_): A function to be called on every tick """ if not self.IS_TESTER: return modelling = self.tester_config["modelling"] if modelling == "real_ticks" or modelling == "every_tick": total_ticks = sum(ticks_info["size"] for ticks_info in self.TESTER_ALL_TICKS_INFO) self.__GetLogger().debug(f"total number of ticks: {total_ticks}") with tqdm(total=total_ticks, desc="Tester Progress", unit="tick") as pbar: while True: any_tick_processed = False for ticks_info in self.TESTER_ALL_TICKS_INFO: symbol = ticks_info["symbol"] size = ticks_info["size"] counter = ticks_info["counter"] if counter >= size: continue current_tick = ticks_info["ticks"].row(counter) self.TickUpdate(symbol=symbol, tick=current_tick) ontick_func() ticks_info["counter"] = counter + 1 any_tick_processed = True pbar.update(1) if not any_tick_processed: break
Ниже приведён результат, полученный при запуске класса Tester с режимом моделирования every_tick, указанным в конфигурационном JSON-файле.
Tester Progress: 100%|███████████████████████████████████████████████████████████████████████████████████████████████████| 20613922/20613922 [01:31<00:00, 224889.74tick/s]
Неплохо: симуляция полного года по трём торговым инструментам заняла полторы минуты.
Тестирование стратегии на основе НОВОГО БАРА
Это самый быстрый и наименее точный режим моделирования в тестере стратегий MetaTrader 5. Он предполагает тестирование программы только на открытии нового бара. Все тики между открытием и закрытием бара пропускаются.
В конструкторе класса применяем тот же подход, которые использовали при подготовке реальных тиков для симуляции.
На этот раз бары, собранные по каждому торговому инструменту, мы сохраняем в массив с именем TESTER_ALL_BARS_INFO.
tester.py
self.TESTER_ALL_BARS_INFO = [] # for storing all bars to be used during the test for symbol in self.tester_config["symbols"]: if modelling == "real_ticks": # .... elif modelling == "new_bar": bars_obtained = bars.fetch_historical_bars(symbol=symbol, timeframe=utils.TIMEFRAMES[self.tester_config["timeframe"]], start_datetime=start_dt, end_datetime=end_dt) bars_info = { "symbol": symbol, "bars": bars_obtained, "size": bars_obtained.height, "counter": 0 } self.TESTER_ALL_BARS_INFO.append(bars_info)
Внутри функции OnTick мы проходим циклом по всем собранным барам.
def OnTick(self, ontick_func): #.... elif modelling == "new_bar": bars_ = [bars_info["size"] for bars_info in self.TESTER_ALL_BARS_INFO] total_bars = sum(bars_) self.__GetLogger().debug(f"total number of bars: {total_bars}") with tqdm(total=total_bars, desc="Tester Progress", unit="bar") as pbar: while True: self.__account_monitoring() self.__positions_monitoring() self.__pending_orders_monitoring() any_bar_processed = False for bars_info in self.TESTER_ALL_BARS_INFO: symbol = bars_info["symbol"] size = bars_info["size"] counter = bars_info["counter"] if counter >= size: continue current_tick = self._bar_to_tick(symbol=symbol, bar=bars_info["bars"].row(counter)) self.TickUpdate(symbol=symbol, tick=current_tick) ontick_func() bars_info["counter"] = counter + 1 any_bar_processed = True pbar.update(1) if not any_bar_processed: break
Однако наш класс Tester во многом опирается на тики; поскольку бар отличается от тика, мы генерируем тики на открытии бара с помощью функции ниже:
def _bar_to_tick(self, symbol, bar): """ Creates a synthetic tick from a bar (MT5-style). Uses OPEN price. """ price = bar["open"] if isinstance(bar, dict) else bar[1] time = bar["time"] if isinstance(bar, dict) else bar[0] spread = bar["spread"] if isinstance(bar, dict) else bar[6] tv = bar["tick_volume"] if isinstance(bar, dict) else bar[5] return { "time": time, "bid": price, "ask": price + spread * self.symbol_info(symbol).point, "last": price, "volume": tv, "time_msc": time.timestamp(), "flags": 0, "volume_real": 0, }
Текущую цену открытия мы рассматриваем как цену bid.
Предполагается, что цена ask равна сумме текущей цены открытия и спреда конкретного бара в пунктах.
Ниже приведён результат, полученный при запуске класса с режимом моделирования new_bar.
Tester Progress: 100%|██████████████████████████████████████████████████████████████████████████████████████████████████████████| 18579/18579 [00:00<00:00, 158324.66bar/s]
Тест выполнился мгновенно, что и ожидается при использовании режима моделирования new_bar.
Тестирование стратегии на 1-минутных OHLC
Этот тип моделирования предназначен для обеспечения оптимального баланса между точностью и скоростью тестера стратегий при проверке программ в MetaTrader 5.
Когда выбран этот режим, терминал использует бары с 1-минутного графика для генерации тиков на открытии бара — аналогично тому, как мы генерировали тики для режима new_bar.
Мы получаем бары тем же способом, что и в предыдущем методе моделирования; единственное отличие заключается в аргументе таймфрейма.
elif modelling == "1-minute-ohlc": bars_obtained = bars.fetch_historical_bars(symbol=symbol, timeframe=utils.TIMEFRAMES["M1"], start_datetime=start_dt, end_datetime=end_dt) bars_info = { "symbol": symbol, "bars": bars_obtained, "size": bars_obtained.height, "counter": 0 } self.TESTER_ALL_BARS_INFO.append(bars_info)
И снова эти два режима моделирования похожи, поэтому мы используем тот же цикл, что и для метода моделирования new_bar.
elif modelling == "new_bar" or modelling == "1-minute-ohlc": bars_ = [bars_info["size"] for bars_info in self.TESTER_ALL_BARS_INFO] total_bars = sum(bars_) self.__GetLogger().debug(f"total number of bars: {total_bars}") with tqdm(total=total_bars, desc="Tester Progress", unit="bar") as pbar: while True: any_bar_processed = False for bars_info in self.TESTER_ALL_BARS_INFO: symbol = bars_info["symbol"] size = bars_info["size"] counter = bars_info["counter"] if counter >= size: continue current_tick = self._bar_to_tick(symbol=symbol, bar=bars_info["bars"].row(counter)) # Getting ticks at the current bar self.TickUpdate(symbol=symbol, tick=current_tick) ontick_func() bars_info["counter"] = counter + 1 any_bar_processed = True pbar.update(1) if not any_bar_processed: break
configs/tester.json:
{
"tester": {
"bot_name": "MY EA",
"symbols": ["EURUSD", "USDCAD", "USDJPY"],
"timeframe": "H1",
"start_date": "01.01.2025 00:00",
"end_date": "31.12.2025 00:00",
"modelling" : "1-minute-ohlc",
"deposit": 1000,
"leverage": "1:100"
}
} Ниже приведён результат, полученный при запуске класса:
2026-01-11 17:59:45,462 | DEBUG | MY EA.tester | [tester.py:1940 - OnTick() ] => total number of bars: 1113189 Tester Progress: 100%|██████████████████████████████████████████████████████████████████████████████████████████████████████| 1113189/1113189 [00:07<00:00, 143610.20bar/s]
Этот режим оказался вторым по скорости после метода моделирования new_bar
Объединяем всё внутри функции OnTick
В предыдущих статьях этой серии мы реализовали различные функции для мониторинга счёта, отложенных ордеров и позиций. У нас не было возможности их протестировать, но на этот раз мы задействуем их внутри метода OnTick в нашем классе.
def OnTick(self, ontick_func): """Calls the assigned function upon the receival of new tick(s) Args: ontick_func (_type_): A function to be called on every tick """ if not self.IS_TESTER: return modelling = self.tester_config["modelling"] if modelling == "real_ticks" or modelling == "every_tick": total_ticks = sum(ticks_info["size"] for ticks_info in self.TESTER_ALL_TICKS_INFO) self.__GetLogger().debug(f"total number of ticks: {total_ticks}") with tqdm(total=total_ticks, desc="Tester Progress", unit="tick") as pbar: while True: self.__account_monitoring() self.__positions_monitoring() self.__pending_orders_monitoring() any_tick_processed = False for ticks_info in self.TESTER_ALL_TICKS_INFO: symbol = ticks_info["symbol"] size = ticks_info["size"] counter = ticks_info["counter"] if counter >= size: continue current_tick = ticks_info["ticks"].row(counter) self.TickUpdate(symbol=symbol, tick=current_tick) ontick_func() ticks_info["counter"] = counter + 1 any_tick_processed = True pbar.update(1) if not any_tick_processed: break elif modelling == "new_bar" or modelling == "1-minute-ohlc": bars_ = [bars_info["size"] for bars_info in self.TESTER_ALL_BARS_INFO] total_bars = sum(bars_) self.__GetLogger().debug(f"total number of bars: {total_bars}") with tqdm(total=total_bars, desc="Tester Progress", unit="bar") as pbar: while True: self.__account_monitoring() self.__positions_monitoring() self.__pending_orders_monitoring() any_bar_processed = False for bars_info in self.TESTER_ALL_BARS_INFO: symbol = bars_info["symbol"] size = bars_info["size"] counter = bars_info["counter"] if counter >= size: continue current_tick = self._bar_to_tick(symbol=symbol, bar=bars_info["bars"].row(counter)) # Getting ticks at the current bar self.TickUpdate(symbol=symbol, tick=current_tick) ontick_func() bars_info["counter"] = counter + 1 any_bar_processed = True pbar.update(1) if not any_bar_processed: break
Для режимов нового бара, таких как 1-minute-ohlc и new_bar, мы вызываем эти функции на открытии каждого бара, тогда как для режимов, основанных на тиках, мы вызываем их на каждом тике.
Наконец, несколько торговых действий в тестере стратегий
Теперь давайте создадим нашего самого первого торгового робота с помощью этого симулятора и посмотрим, как он работает.
Прежде всего, мы инициализируем нужный терминал MetaTrader 5 сразу после импорта его модуля, а также других полезных Python-модулей для этого проекта.
example_bot.py
import MetaTrader5 as mt5 from tester import Tester from Trade.Trade import CTrade import json import os import config if not mt5.initialize(): # Initialize MetaTrader5 instance print(f"Failed to Initialize MetaTrader5. Error = {mt5.last_error()}") mt5.shutdown() quit()
Затем мы загружаем конфигурации тестера из файла configs/tester.json.
try: with open(os.path.join(config.CONFIGS_DIR,'tester.json'), 'r', encoding='utf-8') as file: # reading a JSON file # Deserialize the file data into a Python object tester_configs = json.load(file) except Exception as e: raise RuntimeError(e)
Мы инициализируем класс Tester, передавая ему конфигурации и инициализированный экземпляр MetaTrader 5.
tester = Tester(tester_config=tester_configs["tester"], mt5_instance=mt5) # very important
Нам нужны некоторые глобальные переменные, которые выступают в роли входных параметров, часто встречающихся в программах для MetaTrader 5.
symbol = "EURUSD" timeframe = "PERIOD_H1" magic_number = 10012026 slippage = 100 sl = 500 tp = 700
При желании мы создаём экземпляр класса CTrade, чтобы значительно упростить себе работу.
m_trade = CTrade(simulator=tester, magic_number=magic_number, filling_type_symbol=symbol, deviation_points=slippage)
Также нам нужно иметь под рукой информацию о конкретном символе.
symbol_info = tester.symbol_info(symbol=symbol)
Каждому торговому роботу нужна стратегия. Давайте напишем её:
Когда нет открытых позиций такого типа, мы открываем одну позицию и удерживаем её до тех пор, пока она не закроется по стоп-лоссу или тейк-профиту. Затем процесс повторяется.
Иными словами, нам нужна функция для проверки, существует ли позиция с определёнными атрибутами — типом и magic number.
def pos_exists(magic: int, type: int) -> bool: for position in tester.positions_get(): if position.type == type and position.magic == magic: return True return False
Создаём основную функцию для выполнения нашей прекрасной стратегии.
def on_tick(): tick_info = tester.symbol_info_tick(symbol=symbol) ask = tick_info.ask bid = tick_info.bid pts = symbol_info.point if not pos_exists(magic=magic_number, type=mt5.POSITION_TYPE_BUY): # If a position of such kind doesn't exist m_trade.buy(volume=0.1, symbol=symbol, price=ask, sl=ask-sl*pts, tp=ask+tp*pts, comment="Tester buy") # we open a buy position if not pos_exists(magic=magic_number, type=mt5.POSITION_TYPE_SELL): # If a position of such kind doesn't exist m_trade.sell(volume=0.1, symbol=symbol, price=bid, sl=bid+sl*pts, tp=bid-tp*pts, comment="Tester sell") # we open a sell position
В конце программы мы передаём эту функцию методу OnTick из класса Tester.
tester.OnTick(ontick_func=on_tick) # very important! Наконец, мы запускаем действие тестера стратегий.

При внимательном анализе видно несколько торговых операций — открытие и закрытие позиций.
2026-01-11 20:03:42,943 | INFO | MY EA.tester | [tester.py:1335 - order_send() ] => Position: 113161665468402118449 opened! Tester Progress: 79%|██████████████████▏ | 882118/1113189 [00:19<00:05, 46032.14bar/s]2026-01-11 20:03:43,349 | INFO | MY EA.tester | [tester.py:1251 - order_send() ] => Position: 113161665468402118449 closed! 2026-01-11 20:03:43,351 | INFO | MY EA.tester | [tester.py:1335 - order_send() ] => Position: 113161665494484307258 opened! 2026-01-11 20:03:43,353 | INFO | MY EA.tester | [tester.py:1251 - order_send() ] => Position: 113161665462461689618 closed! 2026-01-11 20:03:43,353 | INFO | MY EA.tester | [tester.py:1335 - order_send() ] => Position: 113161665494609542402 opened! Tester Progress: 80%|██████████████████▎ | 886723/1113189 [00:19<00:04, 45496.53bar/s]2026-01-11 20:03:43,452 | INFO | MY EA.tester | [tester.py:1251 - order_send() ] => Position: 113161665494609542402 closed! 2026-01-11 20:03:43,453 | INFO | MY EA.tester | [tester.py:1335 - order_send() ] => Position: 113161665501001632037 opened! 2026-01-11 20:03:43,473 | INFO | MY EA.tester | [tester.py:1251 - order_send() ] => Position: 113161665494484307258 closed! 2026-01-11 20:03:43,474 | INFO | MY EA.tester | [tester.py:1335 - order_send() ] => Position: 113161665502337760048 opened! Tester Progress: 80%|██████████████████▍ | 891275/1113189 [00:19<00:04, 45088.52bar/s]2026-01-11 20:03:43,501 | INFO | MY EA.tester | [tester.py:1251 - order_send() ] => Position: 113161665502337760048 closed!
Отлично, на первый взгляд всё работает. Давайте разберём это подробнее.
Создание отчётов тестера стратегий
После успешного запуска тестера стратегий терминал MetaTrader 5 генерирует так называемый отчёт тестера стратегий. Это отчёт, который содержит статистические показатели по всем операциям, выполненным во время тестирования стратегии.
Такие показатели включают общую чистую прибыль, валовую прибыль/убыток, процент прибыльных сделок программы как по коротким, так и по длинным позициям и так далее.

Помимо отчёта бэктеста, который можно найти в терминале MetaTrader 5, нас интересует HTML-отчёт, который можно извлечь из этого отчёта.

Давайте сгенерируем такой отчёт и в нашем пользовательском тестере стратегий, начав с базового шаблона отчёта.
Reports/template.html
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Strategy Tester</title> <!-- Bootstrap 5 --> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" > <style> body { background: #f8f9fa; } h4 { font-size: 14px; text-align: center; margin: 16px 0 10px; font-weight: 600; } .tester-container { max-width: 1200px; margin: auto; } .table-wrapper { margin: 0 10%; /* 10% space left and right */ } .table { font-size: 10px; width: 100%; /* fill the wrapper */ background: white; } .table th { white-space: nowrap; text-align: center; font-size: 10px; } .table td { white-space: nowrap; font-size: 10px; } </style> </head> <body> <h4 class="mt-4 text-center">Orders</h4> <div class="table-wrapper"> <div class="table-responsive"> <table class="table table-sm table-striped table-bordered align-middle"> <thead class="table-light text-center"> <tr> <th>Open Time</th> <th>Order</th> <th>Symbol</th> <th>Type</th> <th class="text-end">Volume</th> <th class="text-end">Price</th> <th class="text-end">S / L</th> <th class="text-end">T / P</th> <th>Time</th> <th>State</th> <th>Comment</th> </tr> </thead> <tbody> {{ORDER_ROWS}} </tbody> </table> </div> </div> <h4 class="mb-3 text-center">Deals</h4> <div class="table-wrapper"> <div class="table-responsive"> <table class="table table-sm table-striped table-bordered align-middle"> <thead class="table-light text-center"> <tr> <th>Time</th> <th>Deal</th> <th>Symbol</th> <th>Type</th> <th>Entry</th> <th>Volume</th> <th>Price</th> <th>Commission</th> <th>Swap</th> <th>Profit</th> <th>Comment</th> <th>Balance</th> </tr> </thead> <tbody> {{DEAL_ROWS}} </tbody> </table> </div> </div> </body> </html>
I: Запись истории ордеров в отчёт
Начнём с самой простой части этого отчёта — отображения всех ордеров, размещённых или сработавших во время симуляции.
Чтобы вывести эти ордера итеративно, мы обращаемся к массиву внутри класса под названием __orders_history_container__.
Этот массив заполняется каждый раз, когда ордер размещается либо позиция открывается или закрывается внутри метода order_send.
tester.py
def __GenerateTesterReport(self, output_file="Tester report.html"): def render_order_rows(orders): rows = [] for o in orders: rows.append(f""" <tr> <td>{datetime.fromtimestamp(o.time_setup)}</td> <td>{o.ticket}</td> <td>{o.symbol}</td> <td>{utils.ORDER_TYPE_MAP.get(o.type, o.type)}</td> <td class="text-end">{o.volume_initial:.2f} / {o.volume_current:.2f}</td> <td class="text-end">{o.price_open:.5f}</td> <td class="text-end">{"" if o.sl == 0 else f"{o.sl:.5f}"}</td> <td class="text-end">{"" if o.tp == 0 else f"{o.tp:.5f}"}</td> <td>{datetime.fromtimestamp(o.time_done) if o.time_done else ""}</td> <td>{utils.ORDER_STATE_MAP.get(o.state, o.state)}</td> <td>{o.comment}</td> </tr> """) return "\n".join(rows) with open("Reports/template.html", "r", encoding="utf-8") as f: template = f.read() order_rows_html = render_order_rows(self.__orders_history_container__) # we populate table's body html = ( template .replace("{{ORDER_ROWS}}", order_rows_html) ) with open(output_file, "w", encoding="utf-8") as f: f.write(html) print(f"Deals report saved to: {output_file}")
Вывод

II: Запись сделок в отчёт
Поскольку в нашем классе есть массив __deals_history_container__, в котором хранятся все сделки, открытые во время симуляции, мы извлекаем из него информацию аналогично тому, как делали это с ордерами, и подставляем её в шаблон отчёта.
tester.py
def __GenerateTesterReport(self, output_file="Tester report.html"): def render_order_rows(orders): rows = [] for o in orders: rows.append(f""" <tr> <td>{datetime.fromtimestamp(o.time_setup)}</td> <td>{o.ticket}</td> <td>{o.symbol}</td> <td>{utils.ORDER_TYPE_MAP.get(o.type, o.type)}</td> <td class="text-end">{o.volume_initial:.2f} / {o.volume_current:.2f}</td> <td class="text-end">{o.price_open:.5f}</td> <td class="text-end">{"" if o.sl == 0 else f"{o.sl:.5f}"}</td> <td class="text-end">{"" if o.tp == 0 else f"{o.tp:.5f}"}</td> <td>{datetime.fromtimestamp(o.time_done) if o.time_done else ""}</td> <td>{utils.ORDER_STATE_MAP.get(o.state, o.state)}</td> <td>{o.comment}</td> </tr> """) return "\n".join(rows) def render_deal_rows(deals): rows = [] for d in deals: rows.append(f""" <tr> <td>{datetime.fromtimestamp(d.time)}</td> <td>{d.ticket}</td> <td>{d.symbol}</td> <td>{utils.DEAL_TYPE_MAP[d.type]}</td> <td>{utils.DEAL_ENTRY_MAP[d.entry]}</td> <td class="text-end">{d.volume:.2f}</td> <td class="text-end">{d.price:.5f}</td> <td class="text-end">{d.commission:.2f}</td> <td class="text-end">{d.swap:.2f}</td> <td class="text-end">{d.profit:.2f}</td> <td>{d.comment}</td> <td>{round(d.balance, 2)}</td> </tr> """) return "\n".join(rows) with open("Reports/template.html", "r", encoding="utf-8") as f: template = f.read() order_rows_html = render_order_rows(self.__orders_history_container__) deal_rows_html = render_deal_rows(self.__deals_history_container__) # we populate table's body html = ( template .replace("{{ORDER_ROWS}}", order_rows_html) .replace("{{DEAL_ROWS}}", deal_rows_html) ) with open(output_file, "w", encoding="utf-8") as f: f.write(html) print(f"Deals report saved to: {output_file}")
Теперь, если обратить внимание на то, как сделки записываются в отчёте MetaTrader 5, можно заметить, что первая сделка имеет тип balance — она отображает начальный депозит. Ниже показано, как добиться такого же результата.
def __make_balance_deal(self, time: datetime) -> namedtuple: time_sec = int(time.timestamp()) time_msc = int(time.timestamp() * 1000) return self.TradeDeal( ticket=self.__generate_deal_ticket(), order=0, time=time_sec, time_msc=time_msc, type=self.mt5_instance.DEAL_TYPE_BALANCE, entry=self.mt5_instance.DEAL_ENTRY_IN, magic=0, position_id=0, reason=np.nan, volume=np.nan, price=np.nan, commission=0.0, swap=0.0, profit=0.0, fee=0.0, symbol="", balance=self.AccountInfo.balance, comment="", external_id="" )Мы создаём сделку типа balance и добавляем её в массив истории сделок в начале симуляции. Чтобы она стала первой записью в истории.
def __TesterInit(self): self.__deals_history_container__.append( self.__make_balance_deal(time=self.tester_config["start_date"]) ) def __TesterDeinit(self): # generate a report at the end self.__GenerateTesterReport(output_file=f"Reports/{self.tester_config['bot_name']}-report.html")
Обратите внимание: функция генерации отчёта вызывается внутри метода __TesterDeinit. Эта функция предназначена для вызова в конце нашей тестовой симуляции; все математические расчёты, анализ, формирование и сохранение отчёта выполняются внутри этого метода.
Вывод

III: Запись статистики тестера
Чтобы воспроизвести те же метрики, которые предоставляет терминал MetaTrader 5, нам нужно понять, что означает каждая метрика, а затем вручную рассчитать их для нашего отчёта.
| Метрика | Описание | Расчёт (MT5) |
|---|---|---|
| History Quality | Качество используемых исторических данных | (Смоделированные тики ÷ требуемые тики) × 100% |
| Bars | Количество обработанных баров | Общее количество баров за период тестирования |
| Ticks | Количество использованных ценовых тиков | Общее количество обработанных тиковых событий |
| Symbols | Символы, участвующие в тесте | Количество торгуемых символов |
| Total Net Profit | Итоговый торговый результат | Gross Profit + Gross Loss |
| Gross Profit | Общая прибыль по прибыльным сделкам | Σ (profit > 0) |
| Gross Loss | Общий убыток по убыточным сделкам | Σ (loss < 0) |
| Profit Factor | Коэффициент прибыльности | Gross Profit ÷ |Gross Loss| |
| Expected Payoff | Средняя прибыль на сделку | Net Profit ÷ Total Trades |
| Recovery Factor | Способность восстановиться после просадки | Net Profit ÷ Maximal Drawdown |
| Sharpe Ratio | Доходность с учётом риска | Mean(Returns) ÷ Std(Returns) |
| Z-Score | Случайность последовательности сделок | Статистический Z-тест по сериям прибыльных/убыточных сделок |
| AHPR | Арифметическая доходность за период удержания | |
| GHPR | Геометрическая доходность за период удержания | GHPR=(BalanceClose/BalanceOpen)^(1/N) |
| LR Correlation | Сила тренда кривой equity | Correlation(trade index, equity) |
| LR Standard Error | Отклонение от тренда equity | Стандартная ошибка регрессионных остатков |
| Margin Level | Уровень безопасности счёта | (Equity ÷ Margin) × 100% |
| Total Trades | Количество закрытых позиций | Количество закрытых позиций |
| Total Deals | Все записи сделок | Включая частичные закрытия |
| Short Trades (won %) | Процент прибыльных сделок Sell | Прибыльные short-сделки ÷ всего short-сделок × 100 |
| Long Trades (won %) | Процент прибыльных сделок Buy | Прибыльные long-сделки ÷ всего long-сделок × 100 |
| Profit Trades (%) | Доля прибыльных сделок | Прибыльные сделки ÷ всего сделок × 100 |
| Loss Trades (%) | Доля убыточных сделок | Убыточные сделки ÷ всего сделок × 100 |
| Largest Profit Trade | Лучшая отдельная сделка | max(прибыль сделки) |
| Largest Loss Trade | Худшая отдельная сделка | min(прибыль сделки) |
| Average Profit Trade | Средняя прибыль прибыльных сделок | Σ profits ÷ количество прибыльных сделок |
| Average Loss Trade | Средний убыток убыточных сделок | Σ losses ÷ количество убыточных сделок |
| Maximum Consecutive Wins | Самая длинная серия прибыльных сделок | max(количество последовательных выигрышей) |
| Maximum Consecutive Losses | Самая длинная серия убыточных сделок | max(количество последовательных проигрышей) |
| Maximal Consecutive Profit | Наибольшая прибыль серии выигрышей | max(Σ profit в выигрышной серии) |
| Maximal Consecutive Loss | Наибольший убыток серии проигрышей | min(Σ loss в проигрышной серии) |
| Average Consecutive Wins | Средняя длина серии выигрышей | mean(длины выигрышных серий) |
| Average Consecutive Losses | Средняя длина серии проигрышей | mean(длины проигрышных серий) |
| Balance Drawdown Absolute | Просадка баланса от стартового значения | Начальный баланс − минимальный баланс |
| Equity Drawdown Absolute | Просадка equity от стартового значения | Начальное equity − минимальное equity |
| Balance Drawdown Maximal | Максимальная просадка баланса от пика до минимума | max(пик баланса − минимум после пика) |
| Equity Drawdown Maximal | Максимальная просадка equity от пика до минимума | max(пик equity − минимум после пика) |
| Balance Drawdown Relative | Максимальная просадка баланса в % | (Max DD ÷ пиковый баланс) × 100 |
| Equity Drawdown Relative | Максимальная просадка equity в % | (Max DD ÷ пиковый equity) × 100 |
Пока мы реализуем в нашем отчёте некоторые из наиболее часто используемых метрик.
tester.py
def __TesterDeinit(self): profits = [] losses = [] total_trades = 0 max_consec_win_count = 0 max_consec_win_money = 0.0 max_consec_loss_count = 0 max_consec_loss_money = 0.0 max_profit_streak_money = 0.0 max_profit_streak_count = 0 max_loss_streak_money = 0.0 max_loss_streak_count = 0 cur_win_count = 0 cur_win_money = 0.0 cur_loss_count = 0 cur_loss_money = 0.0 win_streaks = [] loss_streaks = [] short_trades_won = 0 long_trades_won = 0 for deal in self.__deals_history_container__: if deal.entry == self.mt5_instance.DEAL_ENTRY_OUT: # a closed position total_trades +=1 profit = deal.profit if profit > 0: # A win profits.append(profit) # reset loss streak if cur_loss_count > 0: loss_streaks.append(cur_loss_count) cur_loss_count = 0 cur_loss_money = 0.0 cur_win_count += 1 cur_win_money += profit # longest win streak if cur_win_count > max_consec_win_count: max_consec_win_count = cur_win_count max_consec_win_money = cur_win_money # most profitable win streak if cur_win_money > max_profit_streak_money: max_profit_streak_money = cur_win_money max_profit_streak_count = cur_win_count if deal.type == self.mt5_instance.DEAL_TYPE_BUY: long_trades_won += 1 if deal.type == self.mt5_instance.DEAL_TYPE_SELL: short_trades_won += 1 else: # A loss losses.append(profit) # reset win streak if cur_win_count > 0: win_streaks.append(cur_win_count) cur_win_count = 0 cur_win_money = 0.0 cur_loss_count += 1 cur_loss_money += profit # longest loss streak if cur_loss_count > max_consec_loss_count: max_consec_loss_count = cur_loss_count max_consec_loss_money = cur_loss_money # largest losing streak if cur_loss_money < max_loss_streak_money: max_loss_streak_money = cur_loss_money max_loss_streak_count = cur_loss_count self.tester_stats["Gross Profit"] = np.sum(profits) if profits else 0 self.tester_stats["Gross Loss"] = np.sum(losses) if losses else 0 self.tester_stats["Net Profit"] = self.tester_stats["Gross Profit"] + self.tester_stats["Gross Loss"] self.tester_stats["Profit Factor"] = self.tester_stats["Gross Profit"] / self.tester_stats["Gross Loss"] self.tester_stats["Expected Payoff"] = ( self.tester_stats["Net Profit"] / total_trades if total_trades > 0 else 0 ) def max_drawdown(curve): peak = curve[0] max_dd = 0.0 for value in curve: peak = max(peak, value) dd = peak - value max_dd = max(max_dd, dd) return max_dd returns = np.diff(self.tester_curves["equity"]) sharpe = ( np.mean(returns) / np.std(returns) if len(returns) > 1 and np.std(returns) > 0 else 0.0 ) self.tester_stats["Sharpe Ratio"] = sharpe self.tester_stats["Equity Drawdown Absolute"] = max_drawdown(self.tester_curves["equity"]) self.tester_stats["Balance Drawdown Absolute"] = max_drawdown(self.tester_curves["balance"]) self.tester_stats["Recovery Factor"] = ( self.tester_stats["Net Profit"] / max(self.tester_stats["Balance Drawdown Absolute"], 1) ) self.tester_stats["Equity Drawdown Relative"] = ( self.tester_stats["Equity Drawdown Absolute"] / max(self.tester_curves["equity"]) * 100 if self.tester_curves["equity"] else 0.0 ) self.tester_stats["Balance Drawdown Relative"] = ( self.tester_stats["Balance Drawdown Absolute"] / max(self.tester_curves["balance"]) * 100 if self.tester_curves["balance"] else 0.0 ) self.tester_stats["Balance Drawdown Maximal"] = max_drawdown(self.tester_curves["balance"]) self.tester_stats["Equity Drawdown Maximal"] = max_drawdown(self.tester_curves["equity"]) self.tester_stats["Total Trades"] = total_trades self.tester_stats["Total Deals"] = len(self.__deals_history_container__) self.tester_stats["Short Trades Won"] = short_trades_won self.tester_stats["Long Trades Won"] = long_trades_won self.tester_stats["Profit Trades"] = len(profits) if profits else 0 self.tester_stats["Loss Trades"] = len(losses) if losses else 0 self.tester_stats["Largest Profit Trade"] = np.max(profits) if profits else 0 self.tester_stats["Largest Loss Trade"] = np.min(losses) if losses else 0 self.tester_stats["Average Profit Trade"] = np.mean(profits) if profits else 0 self.tester_stats["Average Loss Trade"] = np.mean(losses) if losses else 0 self.tester_stats["Maximum Consecutive Wins"] = max_profit_streak_count self.tester_stats["Maximum Consecutive Losses"] = max_loss_streak_count self.tester_stats["Maximum Consecutive Wins Money"] = max_profit_streak_money self.tester_stats["Maximum Consecutive Losses Money"] = max_loss_streak_money self.tester_stats["Average Consecutive Wins"] = np.mean(win_streaks) self.tester_stats["Average Consecutive Losses"] = np.mean(loss_streaks) # AHPR / GHPR self.tester_stats["AHPR"] = np.prod(1 + returns) ** (1/len(returns)) if len(returns) else 0 self.tester_stats["GHPR"] = np.prod(1 + returns) if len(returns) else 0
Внутри функции __GenerateTesterReport мы выводим эти метрики в наш HTML-шаблон аналогично тому, как ранее выводили ордера и сделки.
stats_table = f""" <table class="report-table table-sm table-striped"> <tbody> <tr> <th>Initial Deposit</th><td class="number">{self.tester_config.get('deposit', 0)}</td> <th>Ticks</th><td class="number">{self.tester_stats.get('Ticks', 0)}</td> <th>Symbols</th><td class="number">{self.tester_stats.get('Symbols', 0)}</td> </tr> <tr> <th>Total Net Profit</th><td class="number">{self.tester_stats.get('Net Profit', 0):.2f}</td> <th>Balance Drawdown Absolute</th><td class="number">{self.tester_stats.get('Balance Drawdown Absolute', 0):.2f}</td> <th>Equity Drawdown Absolute</th><td class="number">{self.tester_stats.get('Equity Drawdown Absolute', 0):.2f}</td> </tr> <tr> <th>Gross Profit</th><td class="number">{self.tester_stats.get('Gross Profit', 0):.2f}</td> <th>Balance Drawdown Maximal</th><td class="number">{self.tester_stats.get('Balance Drawdown Maximal', 0):.2f}</td> <th>Equity Drawdown Maximal</th><td class="number">{self.tester_stats.get('Equity Drawdown Maximal', 0):.2f}</td> </tr> <tr> <th>Gross Loss</th><td class="number">{self.tester_stats.get('Gross Loss', 0):.2f}</td> <th>Balance Drawdown Relative</th><td class="number">{self.tester_stats.get('Balance Drawdown Relative', 0):.2f}%</td> <th>Equity Drawdown Relative</th><td class="number">{self.tester_stats.get('Equity Drawdown Relative', 0):.2f}%</td> </tr> <tr> <th>Profit Factor</th><td class="number">{self.tester_stats.get('Profit Factor', 0):.2f}</td> <th>Expected Payoff</th><td class="number">{self.tester_stats.get('Expected Payoff', 0):.2f}</td> <th>Margin Level</th><td class="number">{self.tester_stats.get('Margin Level', 0):.2f}%</td> </tr> <tr> <th>Recovery Factor</th><td class="number">{self.tester_stats.get('Recovery Factor', 0):.2f}</td> <th>Sharpe Ratio</th><td class="number">{self.tester_stats.get('Sharpe Ratio', 0):.2f}</td> <th>Z-Score</th><td class="number">{self.tester_stats.get('Z-Score', 0):.2f}</td> </tr> <tr> <th>AHPR</th><td class="number">{self.tester_stats.get('AHPR', 0):.4f}</td> <th>LR Correlation</th><td class="number">{self.tester_stats.get('LR Correlation', 0):.2f}</td> <th>OnTester result</th><td class="number">{self.tester_stats.get('OnTester result', 0)}</td> </tr> <tr> <th>GHPR</th><td class="number">{self.tester_stats.get('GHPR', 0):.4f}</td> <th>LR Standard Error</th><td class="number">{self.tester_stats.get('LR Standard Error', 0):.2f}</td> <td></td><td></td> </tr> <tr> <th>Total Trades</th><td class="number">{self.tester_stats.get('Total Trades', 0)}</td> <th>Short Trades (won %)</th><td class="number">{short_trades_won} ({100*short_trades_won/self.tester_stats.get('Total Trades',1):.2f}%)</td> <th>Long Trades (won %)</th><td class="number">{long_trades_won} ({100*long_trades_won/self.tester_stats.get('Total Trades',1):.2f}%)</td> </tr> <tr> <th>Total Deals</th><td class="number">{self.tester_stats.get('Total Deals', 0)}</td> <th>Profit Trades (% of total)</th><td class="number">{self.tester_stats.get('Profit Trades', 0)} ({100*self.tester_stats.get('Profit Trades',0)/max(self.tester_stats.get('Total Trades',1),1):.2f}%)</td> <th>Loss Trades (% of total)</th><td class="number">{self.tester_stats.get('Loss Trades', 0)} ({100*self.tester_stats.get('Loss Trades',0)/max(self.tester_stats.get('Total Trades',1),1):.2f}%)</td> </tr> <tr> <th>Largest Profit Trade</th><td class="number">{self.tester_stats.get('Largest Profit Trade', 0):.2f}</td> <th>Largest Loss Trade</th><td class="number">{self.tester_stats.get('Largest Loss Trade', 0):.2f}</td> <td></td><td></td> </tr> <tr> <th>Average Profit Trade</th><td class="number">{self.tester_stats.get('Average Profit Trade', 0):.2f}</td> <th>Average Loss Trade</th><td class="number">{self.tester_stats.get('Average Loss Trade', 0):.2f}</td> <td></td><td></td> </tr> <tr> <th>Max Consecutive Wins ($)</th><td class="number">{max_profit_streak_count} ({max_profit_streak_money:.2f})</td> <th>Max Consecutive Losses ($)</th><td class="number">{max_loss_streak_count} ({max_loss_streak_money:.2f})</td> <td></td><td></td> </tr> <tr> <th>Max Consecutive Profit (count)</th><td class="number">{max_profit_streak_count} ({max_profit_streak_money:.2f})</td> <th>Max Consecutive Loss (count)</th><td class="number">{max_loss_streak_count} ({max_loss_streak_money:.2f})</td> <td></td><td></td> </tr> <tr> <th>Average Consecutive Wins</th><td class="number">{self.tester_stats.get('Average Consecutive Wins', 0):.2f}</td> <th>Average Consecutive Losses</th><td class="number">{self.tester_stats.get('Average Consecutive Losses', 0):.2f}</td> <td></td><td></td> </tr> </tbody> </table> """ # .... # we populate table's body html = ( template .replace("{{STATS_TABLE}}", stats_table) .replace("{{ORDER_ROWS}}", order_rows_html) .replace("{{DEAL_ROWS}}", deal_rows_html) .replace( "{{CURVE_IMAGE}}", f'<img src="{curve_img}" class="img-fluid curve-img">' if curve_img else "" ) )
Обратите внимание, что мы вывели метрики в таблицу, в отличие от отчёта без таблицы, который генерирует тестер стратегий MetaTrader 5. Таблица делает созданный отчёт более простым и удобным для чтения.
Таблица статистики в нашем отчёте выглядит следующим образом:

После этого нам нужно построить кривую баланса в отчёте.
С помощью matplotlib.
def _plot_tester_curves(self, output_path: str) -> str: curves = self.tester_curves if not curves["time"]: return None # Convert timestamps → datetime times = [ datetime.fromtimestamp(t) if isinstance(t, (int, float)) else t for t in curves["time"] ] plt.figure(figsize=(10, 4)) plt.plot(times, curves["balance"], label="Balance", linewidth=2) plt.plot(times, curves["equity"], label="Equity", linewidth=2) plt.grid(visible=True, which="minor") # plt.plot(times, curves["margin"], label="Margin", linewidth=1, alpha=0.6) plt.legend(loc="upper right") plt.tight_layout() plt.savefig(output_path, dpi=150, transparent=True) plt.close() return output_path
Чтобы получить данные для нашего графика — кривых, — нам нужно сохранять состояние счёта тестера на каждом тике или через несколько итераций. В этой части нужно быть осторожными, поскольку при неправильной реализации это может сильно снизить производительность.
def __curves_update(self, time): if isinstance(time, datetime): time = time.timestamp() minute = int(time) // (config.CURVES_PLOT_INTERVAL_MINS*60) if minute == self.last_curve_minute: return self.last_curve_minute = minute self.tester_curves["time"].append(time) self.tester_curves["balance"].append(self.AccountInfo.balance) self.tester_curves["equity"].append(self.AccountInfo.equity) self.tester_curves["margin"].append(self.AccountInfo.margin)
Приведённый выше метод добавляет текущее время, баланс, equity и маржу в соответствующие массивы, чтобы позднее использовать эти данные для построения графиков.
def OnTick(self, ontick_func): """Calls the assigned function upon the receival of new tick(s) Args: ontick_func (_type_): A function to be called on every tick """ if not self.IS_TESTER: return self.__TesterInit() modelling = self.tester_config["modelling"] if modelling == "real_ticks" or modelling == "every_tick": total_ticks = sum(ticks_info["size"] for ticks_info in self.TESTER_ALL_TICKS_INFO) self.__GetLogger().debug(f"total number of ticks: {total_ticks}") with tqdm(total=total_ticks, desc="Tester Progress", unit="tick") as pbar: while True: # ... current_tick = utils.make_tick_from_tuple(current_tick) self.TickUpdate(symbol=symbol, tick=current_tick) self.__curves_update(current_tick.time) ontick_func() ticks_info["counter"] = counter + 1 any_tick_processed = True pbar.update(1) if not any_tick_processed: break elif modelling == "new_bar" or modelling == "1-minute-ohlc": bars_ = [bars_info["size"] for bars_info in self.TESTER_ALL_BARS_INFO] total_bars = sum(bars_) self.__GetLogger().debug(f"total number of bars: {total_bars}") with tqdm(total=total_bars, desc="Tester Progress", unit="bar") as pbar: while True: any_bar_processed = False for bars_info in self.TESTER_ALL_BARS_INFO: # ... current_tick = self._bar_to_tick(symbol=symbol, bar=bars_info["bars"].row(counter)) self.__curves_update(current_tick["time"]) # Getting ticks at the current bar self.TickUpdate(symbol=symbol, tick=current_tick) ontick_func() bars_info["counter"] = counter + 1 any_bar_processed = True pbar.update(1) if not any_bar_processed: break self.__TesterDeinit()
Наконец, я установил стоп-лосс в 10 раз больше тейк-профита, чтобы мы знали, чего ожидать от результата тестера, — более предсказуемого результата по всем сделкам. Это хороший показатель того, насколько близок к реальности и эффективен наш проект.

Вывод
Я создал похожего торгового робота на MQL5:
#include <Trade\Trade.mqh> #include <Trade\SymbolInfo.mqh> #include <Trade\PositionInfo.mqh> CTrade m_trade; CSymbolInfo m_symbol; CPositionInfo m_position; input int magic_number = 10012026; input int stoploss = 1000; input int takeprofit = 100; input int slippage = 100; //+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit() { //--- m_symbol.Name(Symbol()); m_trade.SetExpertMagicNumber(magic_number); m_trade.SetDeviationInPoints(slippage); m_trade.SetTypeFillingBySymbol(Symbol()); //--- return(INIT_SUCCEEDED); } //+------------------------------------------------------------------+ //| Expert deinitialization function | //+------------------------------------------------------------------+ void OnDeinit(const int reason) { //--- } //+------------------------------------------------------------------+ //| Expert tick function | //+------------------------------------------------------------------+ void OnTick() { //--- if (!m_symbol.RefreshRates()) return; double ask = m_symbol.Ask(), bid = m_symbol.Bid(), pts = m_symbol.Point(); double volume = 0.01; if (!PosExists(magic_number, POSITION_TYPE_BUY)) m_trade.Buy(volume, Symbol(), ask, ask-stoploss*pts, ask+takeprofit*pts); if (!PosExists(magic_number, POSITION_TYPE_SELL)) m_trade.Sell(volume, Symbol(), bid, bid+stoploss*pts, bid-takeprofit*pts); } //+------------------------------------------------------------------+ //| | //+------------------------------------------------------------------+ bool PosExists(int magic, ENUM_POSITION_TYPE type) { for (int i=PositionsTotal()-1; i>=0; i--) if (m_position.SelectByIndex(i)) if (m_position.Magic() == magic && m_position.PositionType() == type) return true; return false; }
После запуска теста в среде, похожей на пользовательский тестер, был получен следующий результат.

Результаты оказались близкими с точки зрения точности, однако наш симулятор открыл меньше сделок, чем тестер стратегий MetaTrader 5. Такое расхождение было ожидаемым, поскольку в нашем проекте всё ещё есть большой простор для улучшений.
Поделитесь своими мыслями и помогите улучшить этот проект на GitHub: https://github.com/MegaJoctan/PyMetaTester
Итоги
Поскольку нам удалось внедрить знакомый метод обработки тиков и баров, а также пройтись по истории, вызывая основную функцию торговой стратегии, теперь проект можно считать более надёжным.
Отчёт тестера в стиле MetaTrader 5 удобен для тестирования новых функций и отладки нашего пользовательского симулятора. Несмотря на то что он всё ещё содержит ошибки и в нём отсутствуют некоторые метрики, это всё равно лучше, чем ничего, пока мы продолжаем совершенствовать наш Python-тестер стратегий.
Впереди ещё больше — оставайтесь на связи!
Таблица вложений
| Название файла | Описание и использование |
|---|---|
| requirements.txt | Текстовый файл с информацией обо всех Python-зависимостях и их версиях, используемых в этом проекте. |
| configs\tester.json | Конфигурационный JSON-файл, содержащий настраиваемые параметры, используемые тестером. |
| Reports\template.html | HTML-шаблон отчёта тестера, создаваемого классом Tester. |
| src\bars.py | Содержит функции, которые собирают бары из терминала MetaTrader 5 для целей симуляции. |
| src\ticks.py | Содержит функции, которые собирают тики из терминала MetaTrader 5 для целей симуляции. |
| src\ticks_gen.py | Этот скрипт содержит класс с методами, помогающими генерировать тики, похожие на реальные тики, предоставляемые терминалом MetaTrader 5. |
| Trade\Trade.py | Содержит класс CTrade — класс, который упрощает выполнение торговых операций с использованием MetaTrader 5-Python. |
| config.py | Конфигурационный Python-файл. В нём хранятся полезные переменные, используемые в проекте для справки. |
| example_bot.py | Можно рассматривать этот файл как советник, созданный с помощью симулятора, перегруженного функциями MetaTrader 5-Python. |
| tester.py | Содержит класс Tester. Это ядро/движок данного проекта. |
| validators.py | Содержит методы, которые помогают проверять пользовательский ввод и многое другое. |
| utils.py | Утилитный Python-файл, содержащий переиспользуемые методы для всего проекта. |
| Example EA.mq5 | Торговый робот/советник на MQL5 с похожей стратегией на ту, что используется в example_bot.py. Он полезен для сравнения результатов, полученных в тестере стратегий MetaTrader 5 и в нашем пользовательском тестере. |
Перевод с английского произведен MetaQuotes Ltd.
Оригинальная статья: https://www.mql5.com/en/articles/20917
Предупреждение: все права на данные материалы принадлежат MetaQuotes Ltd. Полная или частичная перепечатка запрещена.
Данная статья написана пользователем сайта и отражает его личную точку зрения. Компания MetaQuotes Ltd не несет ответственности за достоверность представленной информации, а также за возможные последствия использования описанных решений, стратегий или рекомендаций.
Переосмысливаем классические стратегии (Часть 15): Стратегия пробоя диапазона предыдущего дня
Тестер стратегий для Python и MetaTrader 5 (Часть 03): Обработка и управление торговыми операциями по образцу MetaTrader 5
Автоматизация торговых стратегий в MQL5 (Часть 28): Создание гармонического паттерна "Летучая мышь" на основе Price Action с визуальной обратной связью
Как заменить WebSocket EA на TradeMux REST в MetaTrader 5
- Бесплатные приложения для трейдинга
- 8 000+ сигналов для копирования
- Экономические новости для анализа финансовых рынков
Вы принимаете политику сайта и условия использования
Для исследователя производительность тестера является важным показателем. Было бы неплохо предоставить данные о потреблении памяти вашим тестером.
0,2 миллиона тиков/секунду - это, к сожалению, сильное ограничение. Возможно, Numba поможет улучшить производительность.
Пожалуйста, добавьте секции (для разного количества торговых символов):
Спасибо за статью!
Для исследователя производительность тестера - важнейший показатель. Было бы неплохо предоставить данные о потреблении памяти тестером.
0,2 миллиона тиков в секунду - это, к сожалению, сильное ограничение. Возможно, Numba поможет повысить производительность.
Пожалуйста, добавьте секции (для разного количества торговых символов):
Спасибо за статью!
Спасибо за предложения, я сделаю это в следующих статьях.
Целью было сначала внедрить, а потом улучшить, но впереди еще долгий путь😊.
Это именно тот инструмент, который я искал! Спасибо большое. Есть ли планы по внедрению оптимизатора параметров в будущем?