English Deutsch 日本語
preview
Тестер стратегий для Python и MetaTrader 5 (Часть 03): Обработка и управление торговыми операциями по образцу MetaTrader 5

Тестер стратегий для Python и MetaTrader 5 (Часть 03): Обработка и управление торговыми операциями по образцу MetaTrader 5

MetaTrader 5Трейдинг |
251 0
Omega J Msigwa
Omega J Msigwa

Оглавление


Введение

В предыдущей статье мы реализовали в нашем симуляторе синтаксис и функции, похожие на те, которые предлагает модуль Python–MetaTrader. Также были созданы аналогичные ордера, сделки, позиции и структуры. В этой статье мы реализуем подход к обработке таких структур, то есть торговых операций, максимально близкий к MetaTrader 5. 


Метод order_send

Все торговые действия в MetaTrader 5, такие как размещение отложенных ордеров, открытие позиций на покупку или продажу, изменение и удаление ордеров, выполняются через вызов одной стандартной функции.

В MQL5 эта функция называется OrderSend, а в Python–MetaTrader 5 — order_send.

Согласно документации:

Метод order_send отправляет запрос на выполнение торговой операции из терминала на торговый сервер. Он аналогичен OrderSend.

order_send(
   request      // request structure
   );

Он принимает один параметр под названием request. Структура типа MqlTradeRequest описывает требуемое торговое действие.

В следующей таблице представлены поля, которые должна содержать "структура запроса".

Поле

Описание

action

Тип торговой операции. Значение может быть одним из значений перечисления TRADE_REQUEST_ACTIONS.

magic

Идентификатор советника. Позволяет организовать аналитическую обработку торговых ордеров. Каждый советник может задать уникальный ID при отправке торгового запроса.

order

Тикет ордера. Требуется для изменения отложенных ордеров.

symbol

Название торгового инструмента, по которому размещается ордер. Не требуется при изменении ордеров и закрытии позиций.

volume

Запрашиваемый объем сделки в лотах. Фактический объем при совершении сделки зависит от типа исполнения ордера.

price

Цена, по которой должен быть исполнен ордер. Цена не задается в случае рыночных ордеров для инструментов с типом исполнения Market Execution (SYMBOL_TRADE_EXECUTION_MARKET), имеющих тип TRADE_ACTION_DEAL.

stoplimit

Цена, по которой будет выставлен отложенный лимитный ордер после того, как цена достигнет значения price (это условие обязательно). До этого момента ордер не передаётся в торговую систему. До этого момента отложенный ордер не передается в торговую систему.

sl

Цена, при которой активируется ордер Stop Loss, если цена движется в неблагоприятном направлении.

tp

Цена, при которой активируется ордер Take Profit, если цена движется в благоприятном направлении.

deviation

Максимально допустимое отклонение от запрошенной цены, указанное в пунктах.

type

Тип ордера. Значение может быть одним из значений перечисления ORDER_TYPE.

type_filling

Тип исполнения ордера. Значение может быть одним из значений ORDER_TYPE_FILLING.

type_time

Тип ордера по сроку действия. Значение может быть одним из значений ORDER_TYPE_TIME.

expiration

Время истечения срока действия отложенного ордера для ордеров типа TIME_SPECIFIED.

comment

Комментарий к ордеру.

position

Тикет позиции. Заполняется при изменении и закрытии позиции для ее точной идентификации. Обычно совпадает с тикетом ордера, который открыл позицию.

position_by

Тикет противоположной позиции. Используется при закрытии позиции путем открытия позиции в противоположном направлении по тому же символу.


Нам нужна аналогичная функция в нашем классе.

    def order_send(self, request: dict):
        """
        Sends a request to perform a trading operation from the terminal to the trade server. The function is similar to OrderSend in MQL5.
        """
        
        if not self.IS_TESTER:
            result = self.mt5_instance.order_send(request)
            if result is None or result.retcode != self.mt5_instance.TRADE_RETCODE_DONE:
                self.__GetLogger().warning(f"MT5 failed: {self.mt5_instance.last_error()}")
                return None
            return result

В предыдущей статье мы добавили в класс симулятора режим тестера стратегий, то есть режим, когда переменная IS_TESTER = True. Если этот режим не используется, симулятор опирается на информацию из клиента MetaTrader 5, а также открывает и управляет всеми торговыми операциями там.

Приведенный выше фрагмент кода отправляет запрос на выставление ордера в MetaTrader 5, когда пользователь не находится в режиме тестера.

В противном случае мы извлекаем параметры запроса.

        action     = request.get("action")
        order_type = request.get("type")
        symbol     = request.get("symbol")
        volume     = float(request.get("volume", 0))
        price      = float(request.get("price", 0))
        sl         = float(request.get("sl", 0))
        tp         = float(request.get("tp", 0))
        ticket     = int(request.get("ticket", -1))
        
        ticks_info = self.tick_cache[symbol]
        
        now = utils.ensure_utc(ticks_info.time)
        ts  = int(now.timestamp())
        msc = int(now.timestamp() * 1000)

Мы должны вручную обрабатывать все операции внутри этой функции, включая запись сделок, а также открытие и закрытие позиций с сохранением данных в контейнерах, то есть, по сути, выполнять работу MetaTrader 5.

I: Размещение отложенных ордеров 

В симуляторе отложенный ордер — это просто информация об ордере, сохраненная во временном массиве-контейнере ордеров (self.__orders_container__).

 if action == self.mt5_instance.TRADE_ACTION_PENDING:
            order_ticket = self.__generate_order_ticket()

            order = self.TradeOrder(
                    ticket=order_ticket,
                    time_setup=ts,
                    time_setup_msc=msc,
                    time_done=0,
                    time_done_msc=0,
                    time_expiration=request.get("expiration", 0),
                    type=order_type,
                    type_time=request.get("type_time", 0),
                    type_filling=request.get("type_filling", 0),
                    state=self.mt5_instance.ORDER_STATE_PLACED,
                    magic=request.get("magic", 0),
                    position_id=0,
                    position_by_id=0,
                    reason=self.mt5_instance.DEAL_REASON_EXPERT,
                    volume_initial=volume,
                    volume_current=volume,
                    price_open=price,
                    sl=sl,
                    tp=tp,
                    price_current=price,
                    price_stoplimit=request.get("price_stoplimit", 0),
                    symbol=symbol,
                    comment=request.get("comment", ""),
                    external_id="",
                )

После создания ордера мы добавляем его в массив-контейнер ордеров, а также записываем его в массив истории ордеров.

            self.__orders_container__.append(order)
            self.__orders_history_container__.append(order)
            
            return {
                "retcode": self.mt5_instance.TRADE_RETCODE_DONE,
                "order": order_ticket,
            }

II: Открытие позиций

В MetaTrader 5 позиции — это контракты, купленные или проданные по финансовому инструменту. Длинная позиция, или long, формируется в результате покупки в ожидании роста цены. Короткая позиция, или short, является результатом продажи актива в ожидании его будущего снижения.

В симуляторе позиция — это набор информации, похожей на данные позиции, сохраненный в контейнере.

        if action == self.mt5_instance.TRADE_ACTION_DEAL:
            

            position_ticket = self.__generate_position_ticket()
            order_ticket    = self.__generate_order_ticket()
            deal_ticket     = self.__generate_deal_ticket()

            position = self.TradePosition(
                ticket=position_ticket,
                time=ts,
                time_msc=msc,
                time_update=ts,
                time_update_msc=msc,
                type=order_type,
                magic=request.get("magic", 0),
                identifier=position_ticket,
                reason=self.mt5_instance.DEAL_REASON_EXPERT,
                volume=volume,
                price_open=price,
                sl=sl,
                tp=tp,
                price_current=price,
                swap=0,
                profit=0,
                symbol=symbol,
                comment=request.get("comment", ""),
                external_id="",
            )
            
            self.__positions_container__.append(position)

Процессы открытия и закрытия позиций имеют действие TRADE_ACTION_DEAL. Результат такой операции можно назвать сделкой, поэтому нам нужно записать такую запись в контейнер сделок.

            self.__deals_history_container__.append(
                self.TradeDeal(
                    ticket=deal_ticket,
                    order=order_ticket,
                    time=ts,
                    time_msc=msc,
                    type=order_type,
                    entry=self.mt5_instance.DEAL_ENTRY_IN,
                    magic=request.get("magic", 0),
                    position_id=position_ticket,
                    reason=self.mt5_instance.DEAL_REASON_EXPERT,
                    volume=volume,
                    price=price,
                    commission=self.__calc_commission(),
                    swap=0,
                    profit=0,
                    fee=0,
                    symbol=symbol,
                    comment=request.get("comment", ""),
                    external_id="",
                )
            )
            
            return {
                "retcode": self.mt5_instance.TRADE_RETCODE_DONE,
                "deal": deal_ticket,
                "order": order_ticket,
                "position": position_ticket,
            }

Сделка — это просто запись в истории об открытии и закрытии позиций в терминале MetaTrader 5.

 III: Закрытие позиций

Запрос на закрытие позиции очень похож на запрос на открытие новой позиции. Оба являются сделками, но с разными значениями entry.

Чтобы определить запрос на закрытие позиции, мы проверяем, есть ли в запросе ключ position, то есть тикет существующей позиции.

        if action == self.mt5_instance.TRADE_ACTION_DEAL:
            
            # ---------- CLOSE POSITION ----------
            
            ticket = request.get("position", -1)
            if ticket != -1:
                pos = next(
                    (p for p in self.__positions_container__ if p.ticket == ticket),
                    None,
                )
                
                if not pos:
                    return {"retcode": self.mt5_instance.TRADE_RETCODE_INVALID}

                self.__positions_container__.remove(pos)
                
                deal_ticket = self.__generate_deal_ticket()
                self.__deals_history_container__.append(
                    self.TradeDeal(
                        ticket=deal_ticket,
                        order=0,
                        time=ts,
                        time_msc=msc,
                        type=order_type,
                        entry=self.mt5_instance.DEAL_ENTRY_OUT,
                        magic=request.get("magic", 0),
                        position_id=pos.ticket,
                        reason=self.mt5_instance.DEAL_REASON_EXPERT,
                        volume=volume,
                        price=price,
                        commission=self.__calc_commission(),
                        swap=0,
                        profit=0,
                        fee=0,
                        symbol=symbol,
                        comment=request.get("comment", ""),
                        external_id="",
                    )
                )

                return {
                    "retcode": self.mt5_instance.TRADE_RETCODE_DONE,
                    "deal": deal_ticket,
                }

Однако мы не можем слепо принимать любой запрос на закрытие позиции. Такие запросы необходимо проверять.

На данный момент есть две важные детали, которые нужно проверить перед записью сделки и удалением позиции из контейнера:

  1. Проверка того, что в запросе указана корректная цена. В MetaTrader 5 позиции Buy закрываются по цене Bid, а позиции Sell — по цене Ask.
  2. Проверка того, что тип ордера в запросе является противоположным типу существующей позиции. То есть если запрос отправлен для существующей позиции Buy (ORDER_TYPE_BUY), она должна закрываться с помощью ORDER_TYPE_SELL.
        if action == self.mt5_instance.TRADE_ACTION_DEAL:
            
            # ---------- CLOSE POSITION ----------
            
            ticket = request.get("position", -1)
            if ticket != -1:
                pos = next(
                    (p for p in self.__positions_container__ if p.ticket == ticket),
                    None,
                )
                
                if not pos:
                    return {"retcode": self.mt5_instance.TRADE_RETCODE_INVALID}

                # validate position close request
                
                if pos.type == order_type:
                    self.__GetLogger().critical("Failed to close an order. Order type must be the opposite")
                    return None
                
                if order_type == self.mt5_instance.ORDER_TYPE_BUY: # For a sell order/position
                    
                    if not TradeValidators.price_equal(a=price, b=ticks_info.ask, eps=pow(10, -symbol_info.digits)):
                        self.__GetLogger().critical(f"Failed to close ORDER_TYPE_SELL. Price {price} is not equal to bid {ticks_info.bid}")
                        return None
                        
                elif order_type == self.mt5_instance.ORDER_TYPE_SELL: # For a buy order/position
                    if not TradeValidators.price_equal(a=price, b=ticks_info.bid, eps=pow(10, -symbol_info.digits)):
                        self.__GetLogger().critical(f"Failed to close ORDER_TYPE_BUY. Price {price} is not equal to bid {ticks_info.bid}")
                        return None
                        
                    
                self.__positions_container__.remove(pos)
                
                deal_ticket = self.__generate_deal_ticket()
                self.__deals_history_container__.append(
                    self.TradeDeal(
                        ticket=deal_ticket,
                        order=0,
                        time=ts,
                        time_msc=msc,
                        type=order_type,
                        entry=self.mt5_instance.DEAL_ENTRY_OUT,
                        magic=request.get("magic", 0),
                        position_id=pos.ticket,
                        reason=self.mt5_instance.DEAL_REASON_EXPERT,
                        volume=volume,
                        price=price,
                        commission=self.__calc_commission(),
                        swap=0,
                        profit=0,
                        fee=0,
                        symbol=symbol,
                        comment=request.get("comment", ""),
                        external_id="",
                    )
                )

                return {
                    "retcode": self.mt5_instance.TRADE_RETCODE_DONE,
                    "deal": deal_ticket,
                }

IV: Изменение позиций

Чтобы изменить позицию, мы фокусируемся только на двух параметрах: Stop Loss и Take Profit.

        elif action == self.mt5_instance.TRADE_ACTION_SLTP:
            
            ticket = request.get("position", -1)

            pos = next((p for p in self.__positions_container__ if p.ticket == ticket), None)
            if not pos:
                return {"retcode": self.mt5_instance.TRADE_RETCODE_INVALID}

            # --- Correct reference prices ---
            entry_price = pos.price_open
            market_price = ticks_info.bid if pos.type == self.mt5_instance.POSITION_TYPE_BUY else ticks_info.ask

            # --- Validate SL / TP relative to ENTRY ---
            if sl > 0:
                if not trade_validators.is_valid_sl(entry=entry_price, sl=sl, order_type=pos.type):
                    return None

            if tp > 0:
                if not trade_validators.is_valid_tp(entry=entry_price, tp=tp, order_type=pos.type):
                    return None

            # --- Validate freeze level against MARKET ---
            if sl > 0:
                if not trade_validators.is_valid_freeze_level(entry=market_price, stop_price=sl, order_type=pos.type):
                    return None

            if tp > 0:
                if not trade_validators.is_valid_freeze_level(entry=market_price, stop_price=tp, order_type=pos.type):
                    return None

            # --- APPLY MODIFICATION ---
            idx = self.__positions_container__.index(pos)

            updated_pos = pos._replace(
                sl=sl,
                tp=tp,
                time_update=ts,
                time_update_msc=msc
            )

            self.__positions_container__[idx] = updated_pos

            return {"retcode": self.mt5_instance.TRADE_RETCODE_DONE}

V: Удаление отложенных ордеров

Это простой процесс удаления ордера из массива-контейнера. Проверки здесь не требуются.

        if action == self.mt5_instance.TRADE_ACTION_REMOVE:
            
            ticket = request.get("order", -1)
            
            self.__orders_container__ = [
                o for o in self.__orders_container__ if o.ticket != ticket
            ]
            
            return {"retcode": self.mt5_instance.TRADE_RETCODE_DONE}

VI: Изменение отложенных ордеров

Чтобы изменить отложенный ордер, мы фокусируемся на пяти важных параметрах: цене открытия ордера, Stop Loss, Take Profit, времени истечения срока действия и Stop Limit.

        elif action == self.mt5_instance.TRADE_ACTION_SLTP:
            
            ticket = request.get("position", -1)

            pos = next((p for p in self.__positions_container__ if p.ticket == ticket), None)
            if not pos:
                return {"retcode": self.mt5_instance.TRADE_RETCODE_INVALID}

            # --- Correct reference prices ---
            entry_price = pos.price_open
            market_price = ticks_info.bid if pos.type == self.mt5_instance.POSITION_TYPE_BUY else ticks_info.ask

            # --- Validate SL / TP relative to ENTRY ---
            if sl > 0:
                if not trade_validators.is_valid_sl(entry=entry_price, sl=sl, order_type=pos.type):
                    return None

            if tp > 0:
                if not trade_validators.is_valid_tp(entry=entry_price, tp=tp, order_type=pos.type):
                    return None

            # --- Validate freeze level against MARKET ---
            if sl > 0:
                if not trade_validators.is_valid_freeze_level(entry=market_price, stop_price=sl, order_type=pos.type):
                    return None

            if tp > 0:
                if not trade_validators.is_valid_freeze_level(entry=market_price, stop_price=tp, order_type=pos.type):
                    return None

            # --- APPLY MODIFICATION ---
            idx = self.__positions_container__.index(pos)

            updated_pos = pos._replace(
                sl=sl,
                tp=tp,
                time_update=ts,
                time_update_msc=msc
            )

            self.__positions_container__[idx] = updated_pos

            return {"retcode": self.mt5_instance.TRADE_RETCODE_DONE}
        

Но все эти действия будут некорректными без базового метода или набора методов, которые в первую очередь проверяют параметры запроса.

В первом симуляторе, реализованном нами в этой серии статей, проверка параметров ордеров была организована не лучшим образом. Теперь мы улучшим этот подход, реализовав отдельный класс для этой задачи.


Класс проверки торговых операций

Как известно, MetaTrader 5 принимает не все переданные запросы. Он проверяет их на некорректные параметры и при обнаружении ошибки отклоняет такие ордера.

Эти параметры проверяются с учетом спецификации конкретного инструмента, типа счета, требований брокера, а иногда и ограничений клиента MetaTrader 5.

I: Проверка правильного размера лота 

Чтобы размер лота был принят MetaTrader 5:

  • он должен быть больше минимально допустимого размера лота для конкретного инструмента;
  • он должен быть меньше максимально допустимого размера лота для конкретного инструмента;
  • он должен быть кратен шагу объема.

Внутри validators.py:

class TradeValidators:
    def __init__(self, symbol_info: namedtuple, ticks_info: any, logger: any, mt5_instance: mt5=mt5):
        
        self.symbol_info = symbol_info
        self.ticks_info = ticks_info
        self.logger = logger
        self.mt5_instance = mt5_instance
        
    def is_valid_lotsize(self, lotsize: float) -> bool:
        
        # Validate lotsize
        
        if lotsize < self.symbol_info.volume_min: # check if the received lotsize is smaller than minimum accepted lot of a symbol
            self.logger.info(f"Trade validation failed: lotsize ({lotsize}) is less than minimum allowed ({self.symbol_info.volume_min})")
            return False
        
        if lotsize > self.symbol_info.volume_max: # check if the received lotsize is greater than the maximum accepted lot
            self.logger.info(f"Trade validation failed: lotsize ({lotsize}) is greater than maximum allowed ({self.symbol_info.volume_max})")
            return False
        
        step_count = lotsize / self.symbol_info.volume_step 
        
        if abs(step_count - round(step_count)) > 1e-7: # check if the stoploss is a multiple of the step size
            self.logger.info(f"Trade validation failed: lotsize ({lotsize}) must be a multiple of step size ({self.symbol_info.volume_step})")
            return False

        return True

II: Проверка наличия достаточных средств для новой позиции

Терминал MetaTrader 5 проверяет, достаточно ли свободной маржи на счете для открытия новой позиции.

Ниже приведена похожая функция для этой задачи.

    def is_there_enough_money(self, margin_required: float, free_margin: float) -> bool:
        
        if margin_required < 0:
            self.logger.info("Trade validation failed: Cannot calculate margin requirements")
            return False
        
        # Check free margin
        if margin_required > free_margin:
            self.logger.info(f'Trade validation failed: Not enough money to open trade. '
                f'Required: {margin_required:.2f}, '
                f'Free margin: {free_margin:.2f}')
            
            return False

        return True

III: Проверка корректной цены входа

Для позиции Buy цена должна быть равна цене Ask, а для позиции Sell — цене Bid. Эта проверка применяется только к позициям. 

    def is_valid_entry(self, price: float, order_type: int) -> bool:
        
        eps = pow(10, -self.symbol_info.digits)
        if order_type == self.mt5_instance.ORDER_TYPE_BUY:  # BUY
            if not self.price_equal(a=price, b=self.ticks_info.ask, eps=eps):
                self.logger.info(f"Trade validation failed: Buy price {price} != ask {self.ticks_info.ask}")
                return False

        elif order_type == self.mt5_instance.ORDER_TYPE_SELL:  # SELL
            if not self.price_equal(a=price, b=self.ticks_info.bid, eps=eps):
                self.logger.info(f"Trade validation failed: Sell price {price} != bid {self.ticks_info.bid}")
                return False
        else:
            self.logger.error("Unknown MetaTrader 5 position type")
            return False

        return True

IV: Проверка того, что Stop Loss и Take Profit не находятся слишком близко к рынку

У каждого символа есть небольшое пороговое значение, показывающее минимальное расстояние, на котором Stop Loss и Take Profit должны находиться от рынка.

Это пороговое значение называется SYMBOL_TRADE_STOPS_LEVEL.

    def is_valid_stops_level(self, entry: float, stop_price: float, stops_type: str='') -> bool:
        
        point = self.symbol_info.point
        stop_level   = self.symbol_info.trade_stops_level * point
        
        distance = abs(entry-stop_price)
        
        if stop_price <= 0:
            return True
        
        if distance < stop_level:
            self.logger.info(f"{'Either SL or TP' if stops_type=='' else stops_type} is too close to the market. Min allowed distance = {stop_level}")
            return False
        
        return True

V: Проверка корректности значений Stop Loss и Take Profit

Для ордера Buy значение Stop Loss должно быть ниже цены входа, а Take Profit — выше цены входа.

Для ордера Sell значение Take Profit должно быть ниже цены входа, а Stop Loss — выше цены входа. 

    def is_valid_sl(self, entry: float, sl: float, order_type: int) -> bool:
        
        if not self.is_valid_stops_level(entry, sl, "Stoploss"): # check for stops levels
            return False
            
        if sl > 0:
            if order_type in self.BUY_ACTIONS: # buy action
                
                if sl >= entry:
                    self.logger.info(f"Trade validation failed: Buy-based order's stop loss ({sl}) must be below order opening price ({entry})")
                    return False
                
            elif order_type in self.SELL_ACTIONS: # sell action
                
                if sl <= entry:
                    self.logger.info(f"Trade validation failed: Sell-based order's stop loss ({sl}) must be above order opening price ({entry})")
                    return False
            
            else:
                self.logger.error("Unknown MetaTrader 5 order type")
                return False
        
        return True

    def is_valid_tp(self, entry: float, tp: float, order_type: int) -> bool:
        
        if not self.is_valid_stops_level(entry, tp, "Takeprofit"): # check for stops and freeze levels
            return False
        
        if tp > 0:
            if order_type in self.BUY_ACTIONS: # buy position
                if tp <= entry:
                    self.logger.info(f"Trade validation failed: {self.ORDER_TYPES_MAP[order_type]} take profit ({tp}) must be above order opening price ({entry})")
                    return False
            elif order_type in self.SELL_ACTIONS: # sell position
                if tp >= entry:
                    self.logger.info(f"Trade validation failed: {self.ORDER_TYPES_MAP[order_type]} take profit ({tp}) must be below order opening price ({entry})")
                    return False
            else:
                self.logger.error("Unknown MetaTrader 5 order type")
                return False
        
        return True

VI: Проверка того, что максимальный объем по инструменту не достигнут

У некоторых символов у некоторых брокеров есть ограничение на общий размер лота по отдельным открытым ордерам и позициям.

    def is_symbol_volume_reached(self, symbol_volume: float, volume_limit: float) -> bool:
        
        """Checks if the maximum allowed volume is reached for a particular instrument

        Returns:
            bool: True if the condition is reached and False when it is not.
        """
    
        if symbol_volume >= volume_limit and volume_limit > 0:
            self.logger.critical(f"Symbol Volume limit of {volume_limit} is reached!")
            return True
        
        return False

VII: Проверка того, что максимальное количество ордеров не достигнуто

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

    def is_max_orders_reached(self, open_orders: int, ac_limit_orders: int) -> bool:
        """Checks whether the maximum number of orders for the account is reached

        Args:
            open_orders (int): The number of opened orders
            ac_limit_orders (int): Maximum number of orders allowed for the account

        Returns:
            bool: True if the threshold is reached, otherwise, it returns false.
        """
        
        if open_orders >= ac_limit_orders and ac_limit_orders > 0:
            self.logger.critical(f"Pending Orders limit of {ac_limit_orders} is reached!")
            return True
        
        return False

VIII: Проверка уровня заморозки (freeze level)

Параметр SYMBOL_TRADE_FREEZE_LEVEL может быть задан в спецификации символа. Он показывает расстояние заморозки торговых операций для отложенных ордеров и открытых позиций в пунктах. Например, если сделка по финансовому инструменту перенаправляется на обработку во внешнюю торговую систему, отложенный ордер Buy Limit может оказаться слишком близко к текущей цене Ask. И если запрос на изменение этого ордера отправляется в момент, когда цена открытия находится достаточно близко к Ask, может случиться так, что ордер уже будет исполнен, и его изменение станет невозможным.
Тип ордера / позиции
Цена активации
Проверка
Buy Limit order
 Ask
Ask-OpenPrice >= SYMBOL_TRADE_FREEZE_LEVEL
Buy Stop order  Ask OpenPrice-Ask >= SYMBOL_TRADE_FREEZE_LEVEL
Sell Limit order  Bid OpenPrice-Bid >= SYMBOL_TRADE_FREEZE_LEVEL
Sell Stop order  Bid Bid-OpenPrice >= SYMBOL_TRADE_FREEZE_LEVEL
Позиция Buy
 Bid TakeProfit-Bid >= SYMBOL_TRADE_FREEZE_LEVEL
Bid-StopLoss >= SYMBOL_TRADE_FREEZE_LEVEL
позиция Sell
 Ask Ask-TakeProfit >= SYMBOL_TRADE_FREEZE_LEVEL
StopLoss-Ask >= SYMBOL_TRADE_FREEZE_LEVEL
    def is_valid_freeze_level(self, entry: float, stop_price: float, order_type: int) -> bool:
        """
        Check SYMBOL_TRADE_FREEZE_LEVEL for pending orders and open positions.
        """

        freeze_level = self.symbol_info.trade_freeze_level
        if freeze_level <= 0:
            return True  # No freeze restriction

        point = self.symbol_info.point
        freeze_distance = freeze_level * point

        bid = self.ticks_info.bid
        ask = self.ticks_info.ask

        def log_fail(msg: str, dist: float):
            self.logger.info(
                f"{msg} | distance={dist/point:.1f} pts < "
                f"freeze_level={freeze_level} pts"
            )

        # ---------------- Pending Orders ----------------

        if order_type == self.mt5_instance.ORDER_TYPE_BUY_LIMIT:
            dist = ask - entry
            if dist < freeze_distance:
                log_fail("BuyLimit cannot be modified: Ask - OpenPrice", dist)
                return False
            return True

        if order_type == self.mt5_instance.ORDER_TYPE_SELL_LIMIT:
            dist = entry - bid
            if dist < freeze_distance:
                log_fail("SellLimit cannot be modified: OpenPrice - Bid", dist)
                return False
            return True

        if order_type == self.mt5_instance.ORDER_TYPE_BUY_STOP:
            dist = entry - ask
            if dist < freeze_distance:
                log_fail("BuyStop cannot be modified: OpenPrice - Ask", dist)
                return False
            return True

        if order_type == self.mt5_instance.ORDER_TYPE_SELL_STOP:
            dist = bid - entry
            if dist < freeze_distance:
                log_fail("SellStop cannot be modified: Bid - OpenPrice", dist)
                return False
            return True

        # ---------------- Open Positions (SL / TP modification) ----------------

        # Buy position
        if order_type == self.mt5_instance.ORDER_TYPE_BUY:
            if stop_price <= 0:
                return True

            if stop_price < entry:  # StopLoss
                dist = bid - stop_price
                if dist < freeze_distance:
                    log_fail("Buy position SL cannot be modified: Bid - SL", dist)
                    return False
            else:  # TakeProfit
                dist = stop_price - bid
                if dist < freeze_distance:
                    log_fail("Buy position TP cannot be modified: TP - Bid", dist)
                    return False

            return True

        # Sell position
        if order_type == self.mt5_instance.ORDER_TYPE_SELL:
            if stop_price <= 0:
                return True

            if stop_price > entry:  # StopLoss
                dist = stop_price - ask
                if dist < freeze_distance:
                    log_fail("Sell position SL cannot be modified: SL - Ask", dist)
                    return False
            else:  # TakeProfit
                dist = ask - stop_price
                if dist < freeze_distance:
                    log_fail("Sell position TP cannot be modified: Ask - TP", dist)
                    return False

            return True

        self.logger.error("Unknown MetaTrader 5 order type")
        return False


Все валидаторы внутри order_send — кратко

После того как все эти функции были помещены в класс TradeValidators в файле validators.py и применены к функции order_send, все вместе выглядит следующим образом:

    def order_send(self, request: dict):
        """
        Sends a request to perform a trading operation from the terminal to the trade server. The function is similar to OrderSend in MQL5.
        """

        # -----------------------------------------------------
        
        if not self.IS_TESTER:
            result = self.mt5_instance.order_send(request)
            if result is None or result.retcode != self.mt5_instance.TRADE_RETCODE_DONE:
                self.__GetLogger().warning(f"MT5 failed: {self.mt5_instance.last_error()}")
                return None
            return result

        # -------------------- Extract request -----------------------------
        
        action     = request.get("action")
        order_type = request.get("type")
        symbol     = request.get("symbol")
        volume     = float(request.get("volume", 0))
        price      = float(request.get("price", 0))
        sl         = float(request.get("sl", 0))
        tp         = float(request.get("tp", 0))
        ticket     = int(request.get("ticket", -1))
        
        ticks_info = self.tick_cache[symbol]
        symbol_info = self.symbol_info(symbol)
        ac_info = self.account_info()
        
        now = ticks_info.time
        ts  = int(now)
        msc = int(now * 1000)
        
        if order_type not in self.ORDER_TYPES:
            return {"retcode": self.mt5_instance.TRADE_RETCODE_INVALID}
        
        
        trade_validators = TradeValidators(symbol_info=symbol_info, 
                                           ticks_info=ticks_info, 
                                           logger=self.__GetLogger(), 
                                           mt5_instance=self.mt5_instance)
        
        # -------------------- REMOVE pending order ------------------------
        
        if action == self.mt5_instance.TRADE_ACTION_REMOVE:
            
            ticket = request.get("order", -1)
            
            self.__orders_container__ = [
                o for o in self.__orders_container__ if o.ticket != ticket
            ]
            
            return {"retcode": self.mt5_instance.TRADE_RETCODE_DONE}

        # --------------------- PENDING order --------------------------
        
        if action == self.mt5_instance.TRADE_ACTION_PENDING:
            
            if trade_validators.is_max_orders_reached(open_orders=len(self.__orders_container__), 
                                                      ac_limit_orders=ac_info.limit_orders):
                return None
            
            if not trade_validators.is_valid_sl(entry=price, sl=sl, order_type=order_type) or not trade_validators.is_valid_tp(entry=price, tp=tp, order_type=order_type):
                return None
            
            total_volume = sum([pos.volume for pos in self.__positions_container__]) + sum([order.volume for order in self.__orders_container__])
            if trade_validators.is_symbol_volume_reached(symbol_volume=total_volume, volume_limit=symbol_info.volume_limit):
                return None
            
            order_ticket = self.__generate_order_ticket()

            order = self.TradeOrder(
                    ticket=order_ticket,
                    time_setup=ts,
                    time_setup_msc=msc,
                    time_done=0,
                    time_done_msc=0,
                    time_expiration=request.get("expiration", 0),
                    type=order_type,
                    type_time=request.get("type_time", 0),
                    type_filling=request.get("type_filling", 0),
                    state=self.mt5_instance.ORDER_STATE_PLACED,
                    magic=request.get("magic", 0),
                    position_id=0,
                    position_by_id=0,
                    reason=self.mt5_instance.DEAL_REASON_EXPERT,
                    volume_initial=volume,
                    volume_current=volume,
                    price_open=price,
                    sl=sl,
                    tp=tp,
                    price_current=price,
                    price_stoplimit=request.get("price_stoplimit", 0),
                    symbol=symbol,
                    comment=request.get("comment", ""),
                    external_id="",
                )
                
            self.__orders_container__.append(order) 
            self.__orders_history_container__.append(order)
            
            return {
                "retcode": self.mt5_instance.TRADE_RETCODE_DONE,
                "order": order_ticket,
            }

        # ------------------ MARKET DEAL (open or close) ------------------
        
        if action == self.mt5_instance.TRADE_ACTION_DEAL:
            
            # ---------- CLOSE POSITION ----------
            
            ticket = request.get("position", -1)
            if ticket != -1:
                pos = next(
                    (p for p in self.__positions_container__ if p.ticket == ticket),
                    None,
                )
                
                if not pos:
                    return {"retcode": self.mt5_instance.TRADE_RETCODE_INVALID}

                # validate position close request
                
                if pos.type == order_type:
                    self.__GetLogger().critical("Failed to close an order. Order type must be the opposite")
                    return None
                
                if order_type == self.mt5_instance.ORDER_TYPE_BUY: # For a sell order/position
                    
                    if not TradeValidators.price_equal(a=price, b=ticks_info.ask, eps=pow(10, -symbol_info.digits)):
                        self.__GetLogger().critical(f"Failed to close ORDER_TYPE_SELL. Price {price} is not equal to bid {ticks_info.bid}")
                        return None
                        
                elif order_type == self.mt5_instance.ORDER_TYPE_SELL: # For a buy order/position
                    if not TradeValidators.price_equal(a=price, b=ticks_info.bid, eps=pow(10, -symbol_info.digits)):
                        self.__GetLogger().critical(f"Failed to close ORDER_TYPE_BUY. Price {price} is not equal to bid {ticks_info.bid}")
                        return None
                        
                    
                self.__positions_container__.remove(pos)
                
                deal_ticket = self.__generate_deal_ticket()
                self.__deals_history_container__.append(
                    self.TradeDeal(
                        ticket=deal_ticket,
                        order=0,
                        time=ts,
                        time_msc=msc,
                        type=order_type,
                        entry=self.mt5_instance.DEAL_ENTRY_OUT,
                        magic=request.get("magic", 0),
                        position_id=pos.ticket,
                        reason=self.mt5_instance.DEAL_REASON_EXPERT,
                        volume=volume,
                        price=price,
                        commission=self.__calc_commission(),
                        swap=0,
                        profit=0,
                        fee=0,
                        symbol=symbol,
                        comment=request.get("comment", ""),
                        external_id="",
                    )
                )

                return {
                    "retcode": self.mt5_instance.TRADE_RETCODE_DONE,
                    "deal": deal_ticket,
                }
                
            # ---------- OPEN POSITION ----------
            
            # validate new stops 
            
            if not trade_validators.is_valid_sl(entry=price, sl=sl, order_type=order_type):
                return None
            if not trade_validators.is_valid_tp(entry=price, tp=tp, order_type=order_type):
                return None
            
            # validate the lotsize
            
            if not trade_validators.is_valid_lotsize(lotsize=volume):
                return None
            
            total_volume = sum([pos.volume for pos in self.__positions_container__]) + sum([order.volume for order in self.__orders_container__])
            if trade_validators.is_symbol_volume_reached(symbol_volume=total_volume, volume_limit=symbol_info.volume_limit):
                return None
            
            
            if not trade_validators.is_there_enough_money(margin_required=self.order_calc_margin(order_type=order_type, 
                                                                                                 symbol=symbol,
                                                                                                 volume=volume,
                                                                                                 price=price), 
                                                          free_margin=ac_info.margin_free):
                return None
            
            position_ticket = self.__generate_position_ticket()
            order_ticket    = self.__generate_order_ticket()
            deal_ticket     = self.__generate_deal_ticket()

            position = self.TradePosition(
                ticket=position_ticket,
                time=ts,
                time_msc=msc,
                time_update=ts,
                time_update_msc=msc,
                type=order_type,
                magic=request.get("magic", 0),
                identifier=position_ticket,
                reason=self.mt5_instance.DEAL_REASON_EXPERT,
                volume=volume,
                price_open=price,
                sl=sl,
                tp=tp,
                price_current=price,
                swap=0,
                profit=0,
                symbol=symbol,
                comment=request.get("comment", ""),
                external_id="",
            )
            
            self.__positions_container__.append(position)

            self.__deals_history_container__.append(
                self.TradeDeal(
                    ticket=deal_ticket,
                    order=order_ticket,
                    time=ts,
                    time_msc=msc,
                    type=order_type,
                    entry=self.mt5_instance.DEAL_ENTRY_IN,
                    magic=request.get("magic", 0),
                    position_id=position_ticket,
                    reason=self.mt5_instance.DEAL_REASON_EXPERT,
                    volume=volume,
                    price=price,
                    commission=self.__calc_commission(),
                    swap=0,
                    profit=0,
                    fee=0,
                    symbol=symbol,
                    comment=request.get("comment", ""),
                    external_id="",
                )
            )
            
            return {
                "retcode": self.mt5_instance.TRADE_RETCODE_DONE,
                "deal": deal_ticket,
                "order": order_ticket,
                "position": position_ticket,
            }
            
        elif action == self.mt5_instance.TRADE_ACTION_MODIFY: # Modifying pending orders

            ticket = request.get("order", -1)

            order = next(
                (o for o in self.__orders_container__ if o.ticket == ticket),
                None,
            )

            if not order:
                return {"retcode": self.mt5_instance.TRADE_RETCODE_INVALID}

            # validate new stops 
            
            if not trade_validators.is_valid_freeze_level(entry=price, stop_price=sl, order_type=order_type):
                return None
            if not trade_validators.is_valid_freeze_level(entry=price, stop_price=tp, order_type=order_type):
                return None
                
            # Modify ONLY allowed fields
            order.price_open      = price
            order.sl              = sl
            order.tp              = tp
            order.time_expiration = request.get("expiration", order.time_expiration)
            order.price_stoplimit = request.get("price_stoplimit", order.price_stoplimit)

            return {"retcode": self.mt5_instance.TRADE_RETCODE_DONE}
            
        elif action == self.mt5_instance.TRADE_ACTION_SLTP: # Modifying an open position
            
            ticket = request.get("position", -1)

            pos = next(
                (p for p in self.__positions_container__ if p.ticket == ticket),
                None,
            )

            if not pos:
                return {"retcode": self.mt5_instance.TRADE_RETCODE_INVALID}

            # Check for valid stoplosses and TPs ensuring they are not too close to the market
            
            if pos.type == self.mt5_instance.ORDER_TYPE_BUY:
                if not trade_validators.is_valid_sl(entry=ticks_info.bid, sl=sl, order_type=order_type) or not trade_validators.is_valid_tp(entry=ticks_info.bid, tp=tp, order_type=order_type):
                    return None
                
            elif pos.type == self.mt5_instance.ORDER_TYPE_SELL:
                if not trade_validators.is_valid_sl(entry=ticks_info.ask, sl=sl, order_type=order_type) or not trade_validators.is_valid_tp(entry=ticks_info.ask, tp=tp, order_type=order_type):
                    return None
            
            if not trade_validators.is_valid_freeze_level(entry=price, stop_price=sl, order_type=order_type):
                return None
            if not trade_validators.is_valid_freeze_level(entry=price, stop_price=sl, order_type=order_type):
                return None
            
            pos.sl = sl
            pos.tp = tp
            pos.time_update = ts
            pos.time_update_msc = msc

            return {"retcode": self.mt5_instance.TRADE_RETCODE_DONE}

        return {
            "retcode": self.mt5_instance.TRADE_RETCODE_INVALID,
            "comment": "Unsupported trade action",
        }


Класс CTrade внутри симулятора

Как и MQL5, модуль Python-MetaTrader 5 ощущается как низкоуровневый модуль, который позволяет нам взаимодействовать с терминалом MetaTrader 5. Как мы только что увидели, для отправки запроса на открытие позиций и ордеров требуется больше действий, чем хотелось бы (долгий и утомительный процесс).

В MQL5 у нас есть так называемые торговые классы, которые предоставляют простой интерфейс для открытия сделок и управления ими. В Python мы создали похожие классы; теперь адаптируем класс CTrade под нужды нашего симулятора.

import MetaTrader5 as mt5
from datetime import datetime, timezone
import config

class CTrade:
    
    def __init__(self, simulator, magic_number: int, filling_type_symbol: str, deviation_points: int):
        
        self.simulator = simulator
        self.mt5_instance = simulator.mt5_instance
        self.magic_number = magic_number
        self.deviation_points = deviation_points
        self.filling_type = self._get_type_filling(filling_type_symbol)
        
        if self.filling_type == -1:
            print("Failed to initialize the class, Invalid filling type. Check your symbol")
            return

Мы передаём нашему классу экземпляр симулятора, чтобы получать некоторые методы из него, а не напрямую из MetaTrader 5. Это помогает классу использовать переопределённые методы, которые обсуждались в предыдущей статье.

Класс CTrade содержит множество методов, и все они опираются на метод order_send, который входит в модуль Python-MetaTrader 5.

Во всём классе вместо вызова методов из модуля Python-MetaTrader 5 мы вызываем переопределённые методы из класса Simulator.

class CTrade:
    
    def __init__(self, simulator, magic_number: int, filling_type_symbol: str, deviation_points: int):
        
        self.simulator = simulator
        self.mt5_instance = simulator.mt5_instance
        self.magic_number = magic_number
        self.deviation_points = deviation_points
        self.filling_type = self._get_type_filling(filling_type_symbol)
        
        if self.filling_type == -1:
            print("Failed to initialize the class, Invalid filling type. Check your symbol")
            return
        
    def _get_type_filling(self, symbol):
        
        symbol_info = self.simulator.symbol_info(symbol)
        if symbol_info is None:
            print(f"Failed to get symbol info for {symbol}")
        
        filling_map = {
            1: self.mt5_instance.ORDER_FILLING_FOK,
            2: self.mt5_instance.ORDER_FILLING_IOC,
            4: self.mt5_instance.ORDER_FILLING_BOC,
            8: self.mt5_instance.ORDER_FILLING_RETURN
        }
        
        return filling_map.get(symbol_info.filling_mode, f"Unknown Filling type")
    
    
    def __GetLogger(self):
        if self.simulator.IS_TESTER:
            return config.tester_logger
        
        return config.simulator_logger
    
    def position_open(self, symbol: str, volume: float, order_type: int, price: float, sl: float=0.0, tp: float=0.0, comment: str="") -> bool:
        
        """
        Open a market position (instant execution).
        
        Executes either a buy or sell order at the current market price. This is for immediate
        position opening, not pending orders.
        
        Args:
            symbol: Trading symbol (e.g., "EURUSD", "GBPUSD")
            volume: Trade volume in lots (e.g., 0.1 for micro lot)
            order_type: Trade direction (either ORDER_TYPE_BUY or ORDER_TYPE_SELL)
            price: Execution price. For market orders, this should be the current:
                - Ask price for BUY orders
                - Bid price for SELL orders
            sl: Stop loss price (set to 0.0 to disable)
            tp: Take profit price (set to 0.0 to disable)
            comment: Optional order comment (max 31 characters, will be truncated automatically)
        
        Returns:
            bool: True if position was opened successfully, False otherwise
        """
        
        request = {
            "action": self.mt5_instance.TRADE_ACTION_DEAL,
            "symbol": symbol,
            "volume": volume,
            "type": order_type,
            "price": price,
            "deviation": self.deviation_points,
            "magic": self.magic_number,
            "comment": comment,
            "type_time": self.mt5_instance.ORDER_TIME_GTC,
            "type_filling":  self.filling_type,
        }
        
        if sl > 0.0:
            request["sl"] = sl
        if tp > 0.0:
            request["tp"] = tp
        
        if self.simulator.order_send(request) is None:
            return False
        
        self.__GetLogger().info(f"Position Opened successfully!")
            
        return True    
    
    def order_open(self, symbol: str, volume: float, order_type: int, price: float, sl: float = 0.0, tp: float = 0.0, type_time: int = mt5.ORDER_TIME_GTC, expiration: datetime = None, comment: str = "") -> bool:
        
        """
        Opens a pending order with full control over order parameters.
        
        Args:
            symbol: Trading symbol (e.g., "EURUSD")
            volume: Order volume in lots
            order_type: Order type (ORDER_TYPE_BUY_LIMIT, ORDER_TYPE_SELL_STOP, etc.)
            price: Activation price for pending order
            sl: Stop loss price (0 to disable)
            tp: Take profit price (0 to disable)
            type_time: Order expiration type (default: ORDER_TIME_GTC). Possible values:
                    - ORDER_TIME_GTC (Good-Til-Canceled)
                    - ORDER_TIME_DAY (Good for current day)
                    - ORDER_TIME_SPECIFIED (expires at specific datetime)
                    - ORDER_TIME_SPECIFIED_DAY (expires at end of specified day)
            expiration: Expiration datetime (required for ORDER_TIME_SPECIFIED types)
            comment: Optional order comment (max 31 characters)
        
        Returns:
            bool: True if order was placed successfully, False otherwise
        """
        
        # Validate expiration for time-specific orders
        if type_time in (self.mt5_instance.ORDER_TIME_SPECIFIED, self.mt5_instance.ORDER_TIME_SPECIFIED_DAY) and expiration is None:
            print(f"Expiration required for order type {type_time}")
            return False
        
        request = {
            "action": self.mt5_instance.TRADE_ACTION_PENDING,
            "symbol": symbol,
            "volume": volume,
            "type": order_type,
            "price": price,
            "sl": sl,
            "tp": tp,
            "deviation": self.deviation_points,
            "magic": self.magic_number,
            "comment": comment[:31],  # MT5 comment max length is 31 chars
            "type_time": type_time,
            "type_filling": self.filling_type,
        }
        
        # Add expiration if required
        if type_time in (self.mt5_instance.ORDER_TIME_SPECIFIED, self.mt5_instance.ORDER_TIME_SPECIFIED_DAY) and expiration is not None:
            
            # Convert to broker's expected format (UTC timestamp in milliseconds)
            
            expiration_utc = expiration.astimezone(timezone.utc) if expiration.tzinfo else expiration.replace(tzinfo=timezone.utc)
            request["expiration"] = int(expiration_utc.timestamp() * 1000)            
            
        # Send order
        
        if self.simulator.order_send(request) is None:
            return False
        
        self.__GetLogger().info(f"Order opened successfully!")
        return True
    
    
    def buy(self, volume: float, symbol: str, price: float, sl: float=0.0, tp: float=0.0, comment: str="") -> bool:
        
        """
        Opens a buy (market) position.
        
        Args:
            volume: Trade volume (lot size)
            symbol: Trading symbol (e.g., "EURUSD")
            price: Execution price
            sl: Stop loss price (optional, default=0.0)
            tp: Take profit price (optional, default=0.0)
            comment: Position comment (optional, default="")
        
        Returns:
            bool: True if order was sent successfully, False otherwise
        """
    
        return self.position_open(symbol=symbol, volume=volume, order_type=self.mt5_instance.ORDER_TYPE_BUY, price=price, sl=sl, tp=tp, comment=comment)

Благодаря адаптации этого модуля под нужды симулятора теперь у нас есть простой способ открывать позиции и отложенные ордера. Прежде чем тестировать эти методы, давайте разберёмся, какие изменения были внесены в класс и что необходимо сделать для успешного запуска симулятора.

В предыдущей статье мы представили метод Start, который переводил экземпляр класса симулятора в режим тестера стратегий. В этом режиме всё — или почти все данные внутри класса — становилось виртуальным. 

    def Start(self, IS_TESTER: bool) -> bool: # simulator start
        
        self.IS_TESTER = IS_TESTER

Когда этот режим выбран, то есть установлен в True, симулятор имитирует поведение тестера стратегий MetaTrader 5. Если установить значение False, симулятор уже перестаёт быть полноценным симулятором, поскольку он напрямую полагается на клиент MetaTrader 5 для получения всей информации и открывает сделки именно там. Этот режим был добавлен в класс для целей тестирования, чтобы убедиться, что действия, выполняемые в симуляторе, соответствуют тому, что происходит в клиенте MetaTrader 5.  

После внедрения новых изменений этот метод был удалён. По умолчанию класс вызывается в режиме тестера стратегий, то есть в режиме симулятора. Чтобы перейти в режим MetaTrader 5, обычно для отладки, пользователь должен передать аргумент --mt5 при запуске итогового скрипта.

(venv) C:\Users\Omega Joctan\OneDrive\Documents\PyMetaTester>python test.py --mt5


Выполнение торговых действий в симуляторе

Чтобы протестировать текущую версию симулятора, необходимо выполнить следующие шаги. Файлы прикреплены в конце статьи.

Внутри test.py

01: Инициализация терминала MetaTrader 5

import MetaTrader5 as mt5
from Trade.Trade import CTrade
from datetime import datetime, timedelta
import time
import pytz
from simulator import Simulator, CTrade

if not mt5.initialize(): # Initialize MetaTrader5 instance
    print(f"Failed to Initialize MetaTrader5. Error = {mt5.last_error()}")
    mt5.shutdown()
    quit()

02: Создание экземпляра симулятора 

Мы создаём экземпляр класса симулятора, передавая ему экземпляр приложения MetaTrader 5, баланс счёта, обозначенный как deposit, и значение кредитного плеча.

sim = Simulator(simulator_name="MySimulator", mt5_instance=mt5, deposit=1078.30, leverage="1:500")

Затем мы используем экземпляр симулятора для класса CTrade.

03: Необязательно: создание экземпляра класса CTrade

symbol = "EURUSD"
timeframe = mt5.TIMEFRAME_H1
m_trade = CTrade(simulator=sim, magic_number=112233, filling_type_symbol=symbol, deviation_points=100)

04: Передача тиковой информации в симулятор и получение её обратно

mt5_ticks = mt5.symbol_info_tick(symbol) # tick source
sim.TickUpdate(symbol=symbol, tick=mt5_ticks) # very important

tick_from_sim = sim.symbol_info_tick(symbol=symbol) # we get ticks back from a class

ask = tick_from_sim.ask
bid = tick_from_sim.bid

05: Наконец, выполнение торговых операций 

symbol_info = sim.symbol_info(symbol=symbol)
lotsize = symbol_info.volume_min

m_trade.buy(
    volume=lotsize,
    symbol=symbol,
    price=ask,
    sl=ask - 100 * symbol_info.point,
    tp=ask + 150 * symbol_info.point,
    comment="Market Buy"
)

m_trade.sell(
    volume=lotsize,
    symbol=symbol,
    price=bid,
    sl=bid + 100 * symbol_info.point,
    tp=bid - 150 * symbol_info.point,
    comment="Market Sell"
)

buy_limit_price = ask - 200 * symbol_info.point

m_trade.buy_limit(
    volume=lotsize,
    symbol=symbol,
    price=buy_limit_price,
    sl=buy_limit_price - 100 * symbol_info.point,
    tp=buy_limit_price + 200 * symbol_info.point,
    comment="Buy Limit"
)

sell_limit_price = bid + 200 * symbol_info.point

m_trade.sell_limit(
    volume=lotsize,
    symbol=symbol,
    price=sell_limit_price,
    sl=sell_limit_price + 100 * symbol_info.point,
    tp=sell_limit_price - 200 * symbol_info.point,
    comment="Sell Limit"
)

buy_stop_price = ask + 150 * symbol_info.point

m_trade.buy_stop(
    volume=lotsize,
    symbol=symbol,
    price=buy_stop_price,
    sl=buy_stop_price - 100 * symbol_info.point,
    tp=buy_stop_price + 300 * symbol_info.point,
    comment="Buy Stop"
)

sell_stop_price = bid - 150 * symbol_info.point

m_trade.sell_stop(
    volume=lotsize,
    symbol=symbol,
    price=sell_stop_price,
    sl=sell_stop_price + 100 * symbol_info.point,
    tp=sell_stop_price - 300 * symbol_info.point,
    comment="Sell Stop"
)

Мы можем проверить, существуют ли эти открытые позиции и ордера в нашем симуляторе.

print(f"positions in a simulator = {sim.positions_total()}:\n",sim.positions_get())
print(f"orders in a simulator = {sim.orders_total()}:\n", sim.orders_get())

Вывод, режим тестера:

(venv) C:\Users\Omega Joctan\OneDrive\Documents\PyMetaTester>python test.py
2026-01-05 15:09:24,504 | INFO     | tester | [Trade.py:85 - position_open() ] => Position Opened successfully!
2026-01-05 15:09:24,513 | INFO     | tester | [Trade.py:85 - position_open() ] => Position Opened successfully!
2026-01-05 15:09:24,515 | INFO     | tester | [Trade.py:148 - order_open() ] => Order opened successfully!
2026-01-05 15:09:24,515 | INFO     | tester | [Trade.py:148 - order_open() ] => Order opened successfully!
2026-01-05 15:09:24,515 | INFO     | tester | [Trade.py:148 - order_open() ] => Order opened successfully!
2026-01-05 15:09:24,515 | INFO     | tester | [Trade.py:148 - order_open() ] => Order opened successfully!
positions in a simulator = 2:
 (TradePosition(ticket=113127357728313068862, time=1767622158, time_msc=1767622158000, time_update=1767622158, time_update_msc=1767622158000, type=0, magic=112233, identifier=113127357728313068862, reason=3, volume=0.01, price_open=1.16792, sl=1.1669200000000002, tp=1.1694200000000001, price_current=1.16792, swap=0, profit=0, symbol='EURUSD', comment='Market Buy', external_id=''), 
TradePosition(ticket=113127357728890995262, time=1767622158, time_msc=1767622158000, time_update=1767622158, time_update_msc=1767622158000, type=1, magic=112233, identifier=113127357728890995262, reason=3, volume=0.01, price_open=1.16792, sl=1.16892, tp=1.16642, price_current=1.16792, swap=0, profit=0, symbol='EURUSD', comment='Market Sell', external_id=''))
orders in a simulator = 4:
 (TradeOrder(ticket=113127357729019468800, time_setup=1767622158, time_setup_msc=1767622158000, time_done=0, time_done_msc=0, time_expiration=0, type=2, type_time=0, type_filling=1, state=1, magic=112233, position_id=0, position_by_id=0, reason=3, volume_initial=0.01, volume_current=0.01, price_open=1.16592, sl=1.1649200000000002, tp=1.16792, price_current=1.16592, price_stoplimit=0, symbol='EURUSD', comment='Buy Limit', external_id=''), 
TradeOrder(ticket=113127357729019468835, time_setup=1767622158, time_setup_msc=1767622158000, time_done=0, time_done_msc=0, time_expiration=0, type=3, type_time=0, type_filling=1, state=1, magic=112233, position_id=0, position_by_id=0, reason=3, volume_initial=0.01, volume_current=0.01, price_open=1.16992, sl=1.17092, tp=1.16792, price_current=1.16992, price_stoplimit=0, symbol='EURUSD', comment='Sell Limit', external_id=''), 
TradeOrder(ticket=113127357729019468836, time_setup=1767622158, time_setup_msc=1767622158000, time_done=0, time_done_msc=0, time_expiration=0, type=4, type_time=0, type_filling=1, state=1, magic=112233, position_id=0, position_by_id=0, reason=3, volume_initial=0.01, volume_current=0.01, price_open=1.1694200000000001, sl=1.1684200000000002, tp=1.17242, price_current=1.1694200000000001, price_stoplimit=0, symbol='EURUSD', comment='Buy Stop', external_id=''), 
TradeOrder(ticket=113127357729019468803, time_setup=1767622158, time_setup_msc=1767622158000, time_done=0, time_done_msc=0, time_expiration=0, type=5, type_time=0, type_filling=1, state=1, magic=112233, position_id=0, position_by_id=0, reason=3, volume_initial=0.01, volume_current=0.01, price_open=1.16642, sl=1.16742, tp=1.1634200000000001, price_current=1.16642, price_stoplimit=0, symbol='EURUSD', comment='Sell Stop', external_id=''))

Вывод, режим MetaTrader 5

(venv) C:\Users\Omega Joctan\OneDrive\Documents\PyMetaTester>python test.py --mt5
2026-01-05 15:09:29,171 | INFO     | simulator | [Trade.py:85 - position_open() ] => Position Opened successfully!
2026-01-05 15:09:30,270 | INFO     | simulator | [Trade.py:85 - position_open() ] => Position Opened successfully!
2026-01-05 15:09:31,110 | INFO     | simulator | [Trade.py:148 - order_open() ] => Order opened successfully!
2026-01-05 15:09:31,711 | INFO     | simulator | [Trade.py:148 - order_open() ] => Order opened successfully!
2026-01-05 15:09:33,000 | INFO     | simulator | [Trade.py:148 - order_open() ] => Order opened successfully!
2026-01-05 15:09:33,952 | INFO     | simulator | [Trade.py:148 - order_open() ] => Order opened successfully!
positions in a simulator = 2:
 (TradePosition(ticket=1393244663, time=1767622166, time_msc=1767622166713, time_update=1767622166, time_update_msc=1767622166713, type=0, magic=112233, identifier=1393244663, reason=3, volume=0.01, price_open=1.16791, sl=1.1669100000000001, tp=1.16941, price_current=1.16791, swap=0.0, profit=0.0, symbol='EURUSD', comment='Market Buy', external_id=''), 
TradePosition(ticket=1393244666, time=1767622167, time_msc=1767622167817, time_update=1767622167, time_update_msc=1767622167817, type=1, magic=112233, identifier=1393244666, reason=3, volume=0.01, price_open=1.16791, sl=1.16891, tp=1.16641, price_current=1.16791, swap=0.0, profit=0.0, symbol='EURUSD', comment='Market Sell', external_id=''))
orders in a simulator = 4:
 (TradeOrder(ticket=1393244672, time_setup=1767622168, time_setup_msc=1767622168661, time_done=0, time_done_msc=0, time_expiration=0, type=2, type_time=0, type_filling=2, state=1, magic=112233, position_id=0, position_by_id=0, reason=3, volume_initial=0.01, volume_current=0.01, price_open=1.16591, sl=1.16491, tp=1.16791, price_current=1.16791, price_stoplimit=0.0, symbol='EURUSD', comment='Buy Limit', external_id=''), 
TradeOrder(ticket=1393244676, time_setup=1767622169, time_setup_msc=1767622169494, time_done=0, time_done_msc=0, time_expiration=0, type=3, type_time=0, type_filling=2, state=1, magic=112233, position_id=0, position_by_id=0, reason=3, volume_initial=0.01, volume_current=0.01, price_open=1.16991, sl=1.1709100000000001, tp=1.16791, price_current=1.16791, price_stoplimit=0.0, symbol='EURUSD', comment='Sell Limit', external_id=''), 
TradeOrder(ticket=1393244679, time_setup=1767622170, time_setup_msc=1767622170093, time_done=0, time_done_msc=0, time_expiration=0, type=4, type_time=0, type_filling=2, state=1, magic=112233, position_id=0, position_by_id=0, reason=3, volume_initial=0.01, volume_current=0.01, price_open=1.16941, sl=1.16841, tp=1.17241, price_current=1.16791, price_stoplimit=0.0, symbol='EURUSD', comment='Buy Stop', external_id=''), 
TradeOrder(ticket=1393244687, time_setup=1767622171, time_setup_msc=1767622171748, time_done=0, time_done_msc=0, time_expiration=0, type=5, type_time=0, type_filling=2, state=1, magic=112233, position_id=0, position_by_id=0, reason=3, volume_initial=0.01, volume_current=0.01, price_open=1.16641, sl=1.16741, tp=1.16341, price_current=1.16791, price_stoplimit=0.0, symbol='EURUSD', comment='Sell Stop', external_id=''))


Управление ордерами и позициями в симуляторе

С позициями, хранящимися внутри массивов — контейнеров в классе симулятора, — мы можем выполнять над ними действия: например, обнаруживать определённое поведение или условия, изменять их или даже закрывать.

01: Закрытие позиций

Давайте откроем две отдельные позиции и закроем одну из них.

m_trade.buy(volume=lotsize, symbol=symbol, price=ask, comment="buy pos")
m_trade.sell(volume=lotsize, symbol=symbol, price=bid, comment="sell pos")

print(f"positions in a simulator = {sim.positions_total()}:\n",sim.positions_get())

positions = sim.positions_get()
for pos in positions:
    if pos.symbol == symbol and pos.type == sim.mt5_instance.POSITION_TYPE_BUY: # close a buy position
        m_trade.position_close(ticket=pos.ticket, deviation=10) 
        
print("positions remaining: ", sim.positions_get())

Вывод:

2026-01-05 15:54:50,114 | INFO     | tester | [Trade.py:85 - position_open() ] => Position Opened successfully!
2026-01-05 15:54:50,114 | INFO     | tester | [Trade.py:85 - position_open() ] => Position Opened successfully!
positions in a simulator = 2:
 (TradePosition(ticket=113127532167305337632, time=1767624887, time_msc=1767624887000, time_update=1767624887, time_update_msc=1767624887000, type=0, magic=112233, identifier=113127532167305337632, reason=3, volume=0.01, price_open=1.16743, sl=0.0, tp=0.0, price_current=1.16743, swap=0, profit=0, symbol='EURUSD', comment='buy pos', external_id=''), 
TradePosition(ticket=113127532167305337651, time=1767624887, time_msc=1767624887000, time_update=1767624887, time_update_msc=1767624887000, type=1, magic=112233, identifier=113127532167305337651, reason=3, volume=0.01, price_open=1.16743, sl=0.0, tp=0.0, price_current=1.16743, swap=0, profit=0, symbol='EURUSD', comment='sell pos', external_id=''))
2026-01-05 15:54:50,114 | INFO     | tester | [Trade.py:397 - position_close() ] => Position 113127532167305337632 closed successfully!
positions remaining:  (TradePosition(ticket=113127532167305337651, time=1767624887, time_msc=1767624887000, time_update=1767624887, time_update_msc=1767624887000, type=1, magic=112233, identifier=113127532167305337651, reason=3, volume=0.01, price_open=1.16743, sl=0.0, tp=0.0, price_current=1.16743, swap=0, profit=0, symbol='EURUSD', comment='sell pos', external_id=''),)

Была закрыта только позиция Buy. 

02: Изменение позиций

Как и при закрытии позиций, нам нужно выбрать одну позицию, прежде чем отправлять ей запрос на изменение.

m_trade.buy(volume=lotsize, symbol=symbol, price=ask, comment="buy pos")
m_trade.sell(volume=lotsize, symbol=symbol, price=bid, comment="sell pos")

print(f"positions in a simulator = {sim.positions_total()}:\n",sim.positions_get())

positions = sim.positions_get()
for pos in positions:
    if pos.sl == 0:
        if pos.type == sim.mt5_instance.POSITION_TYPE_BUY:
            m_trade.position_modify(ticket=pos.ticket, sl=pos.price_open - 100 * symbol_info.point, tp=pos.tp) 
        if pos.type == sim.mt5_instance.POSITION_TYPE_SELL:
            m_trade.position_modify(ticket=pos.ticket, sl=pos.price_open + 100 * symbol_info.point, tp=pos.tp) 
        
print("positions after modification\n: ", sim.positions_get())

Выводы:

(venv) C:\Users\Omega Joctan\OneDrive\Documents\PyMetaTester>python test.py
2026-01-05 17:19:11,604 | INFO     | tester | [Trade.py:85 - position_open() ] => Position Opened successfully!
2026-01-05 17:19:11,604 | INFO     | tester | [Trade.py:85 - position_open() ] => Position Opened successfully!
positions in a simulator = 2:
 (TradePosition(ticket=113127856102580262436, time=1767629948, time_msc=1767629948000, time_update=1767629948, time_update_msc=1767629948000, type=0, magic=112233, identifier=113127856102580262436, reason=3, volume=0.01, price_open=1.16789, sl=0.0, tp=0.0, price_current=1.16789, swap=0, profit=0, symbol='EURUSD', comment='buy pos', external_id=''), 
TradePosition(ticket=113127856102710534416, time=1767629948, time_msc=1767629948000, time_update=1767629948, time_update_msc=1767629948000, type=1, magic=112233, identifier=113127856102710534416, reason=3, volume=0.01, price_open=1.16789, sl=0.0, tp=0.0, price_current=1.16789, swap=0, profit=0, symbol='EURUSD', comment='sell pos', external_id=''))
2026-01-05 17:19:11,604 | INFO     | tester | [Trade.py:469 - position_modify() ] => Position 113127856102580262436 modified successfully!
2026-01-05 17:19:11,606 | INFO     | tester | [Trade.py:469 - position_modify() ] => Position 113127856102710534416 modified successfully!
positions after modification
:  (TradePosition(ticket=113127856102580262436, time=1767629948, time_msc=1767629948000, time_update=1767629948, time_update_msc=1767629948000, type=0, magic=112233, identifier=113127856102580262436, reason=3, volume=0.01, price_open=1.16789, sl=1.1668900000000002, tp=0.0, price_current=1.16789, swap=0, profit=0, symbol='EURUSD', comment='buy pos', external_id=''), 
TradePosition(ticket=113127856102710534416, time=1767629948, time_msc=1767629948000, time_update=1767629948, time_update_msc=1767629948000, type=1, magic=112233, identifier=113127856102710534416, reason=3, volume=0.01, price_open=1.16789, sl=1.16889, tp=0.0, price_current=1.16789, swap=0, profit=0, symbol='EURUSD', comment='sell pos', external_id=''))

03: Работа с отложенными ордерами

Ниже показано, как можно изменять и удалять отложенные ордера.

m_trade.buy_stop(symbol=symbol, volume=symbol_info.volume_min, price=ask+500*symbol_info.point)

for order in sim.orders_get():
    
    print("order curr price: ", order.price_open)
    
    m_trade.order_modify(ticket=order.ticket, price=order.price_open+10*symbol_info.point, sl=order.sl, tp=order.tp)
    
    print("order moved 10 points upward", order.price_open)
    if m_trade.order_delete(ticket=order.ticket) is None:
        continue
    
    print("orders remaining: ", sim.orders_total())

Выводы:

(venv) C:\Users\Omega Joctan\OneDrive\Documents\PyMetaTester>python test.py
2026-01-05 17:43:15,677 | INFO     | tester | [Trade.py:148 - order_open() ] => Order opened successfully!
order curr price:  1.17281
2026-01-05 17:43:15,677 | INFO     | tester | [Trade.py:536 - order_modify() ] => Order 113127948523388723206 modified successfully!
order moved 10 points upward 1.17291
2026-01-05 17:43:15,677 | INFO     | tester | [Trade.py:431 - order_delete() ] => Order 113127948523388723206 deleted successfully!
orders remaining:  0 


Что дальше?

Возможность использовать тиковые данные за определённый период в прошлом и с их помощью имитировать торговую операцию означает, что мы уже в нескольких шагах от завершения нашего собственного симулятора. Сейчас у нас есть всё необходимое, чтобы создать цикл, который прогонит симуляцию по всем доступным тикам в заданном временном диапазоне. Именно это мы называем тестированием стратегии или полной симуляцией.

В этой статье мы обсудили самый важный аспект любого торгового симулятора — отправку торговых запросов и управление ими. В следующей статье мы соберём всё вместе и запустим наше самое первое тестирование стратегии на Python. Следите за обновлениями!

До встречи в следующей статье.

Поделитесь своими мыслями и помогите улучшить этот проект на GitHub: https://github.com/MegaJoctan/PyMetaTester


Таблица вложений

Название файла Описание и использование
Trade/Trade.py  Содержит класс CTrade — класс, предоставляющий удобный способ выполнения торговых операций.
config.py Файл конфигурации Python, в котором определены наиболее полезные переменные для повторного использования в проекте.
utils.py Вспомогательный Python-файл, содержащий простые функции для помощи в выполнении различных задач.
simulator.py Содержит класс Simulator. В нём собрана основная логика нашего симулятора.
test.py Файл, используемый для тестирования всего кода и функций, рассмотренных в этой публикации.
error_description.py Содержит функции для преобразования всех кодов ошибок MetaTrader 5 в понятные человеку сообщения.
requirements.txt  Содержит все Python-зависимости и их версии, используемые в этом проекте. 


Перевод с английского произведен MetaQuotes Ltd.
Оригинальная статья: https://www.mql5.com/en/articles/20782

Прикрепленные файлы |
Attachments.zip (23.93 KB)
Тестер стратегий для Python и MetaTrader 5 (Часть 04): Основы работы тестера Тестер стратегий для Python и MetaTrader 5 (Часть 04): Основы работы тестера
В этой увлекательной статье мы создадим своего первого торгового робота в симуляторе и запустим тестирование стратегии, напоминающее работу тестера стратегий MetaTrader 5, а затем сравним результат, полученный в пользовательской симуляции, с результатом в нашем любимом терминале.
Как заменить WebSocket EA на TradeMux REST в MetaTrader 5 Как заменить WebSocket EA на TradeMux REST в MetaTrader 5
Статья продолжает серию об AI Hedge Fund и снимает три ограничения v4: репутации аналитиков теперь персистентны в SQLite, EA выведен из критического пути исполнения, а сигналы совета пятнадцати рассылаются на несколько брокеров через TradeMux REST API. Логика совета и риск-менеджмента не менялась: Python получает данные через MetaTrader 5 SDK и исполняет ордера напрямую. Результат — устойчивость к перезапускам и масштабирование на несколько терминалов.
Переосмысливаем классические стратегии (Часть 15): Стратегия пробоя диапазона предыдущего дня Переосмысливаем классические стратегии (Часть 15): Стратегия пробоя диапазона предыдущего дня
Трейдеры-люди уже давно работали на финансовых рынках до появления компьютеров, разработав практические правила, которыми они руководствовались при принятии решений. В этой статье мы вновь рассмотрим хорошо известную стратегию пробоя, чтобы проверить, может ли такая рыночная логика, усвоенная на опыте, конкурировать с систематическими методами. Наши результаты показывают, что, хотя первоначальная стратегия обеспечивала высокую точность, она страдала от нестабильности и слабого контроля рисков. Совершенствуя этот подход, мы продемонстрируем, как инсайты дискреционных трейдеров можно адаптировать в более надежные алгоритмические торговые стратегии.
Автоматизация торговых стратегий в MQL5 (Часть 27): Выявление и визуализация гармонического паттерна "Краб" на основе Price Action Автоматизация торговых стратегий в MQL5 (Часть 27): Выявление и визуализация гармонического паттерна "Краб" на основе Price Action
В этой статье мы разрабатываем систему распознавания гармонических паттернов "Краб" на языке MQL5, которая определяет бычьи и медвежьи гармонические паттерны "Краб" с использованием точек разворота и уровней Фибоначчи, запуская сделки с точными уровнями входа, стоп-лосса и тейк-профита. Мы добавляем визуальное представление с помощью графических объектов, таких как треугольники и линии тренда, для отображения структуры паттерна XABCD и торговых уровней.