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

Тестер стратегий для Python и MetaTrader 5 (Часть 02): Работа с барами, тиками и реализация встроенных функций в симуляторе

MetaTrader 5Тестер |
304 0
Omega J Msigwa
Omega J Msigwa

Оглавление


Введение

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

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


    Обработка исторических тиков MetaTrader 5

    Тики — это наиболее детализированные ценовые обновления финансового инструмента в реальном времени, отражающие каждое отдельное изменение цены, движение bid/ask и торговый объём.

    В отличие от OHLC-баров — Open, High, Low, Close — тики предоставляют данные с точностью до миллисекунд.

    Возможно, вы знакомы с функцией OnTick из языка программирования MQL5. Это основная функция MQL5-ботов, которая вызывается при поступлении нового тика.

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

    С учётом этого нам нужно уметь получать и обрабатывать тики так же, как это делает терминал.

    Модуль Python–MetaTrader 5 предоставляет различные способы получения тиков. Один из них — использование функции copy_ticks_range:

    copy_ticks_range(
       symbol,       // symbol name
       date_from,    // date the ticks are requested from
       date_to,      // date, up to which the ticks are requested
       flags         // combination of flags defining the type of requested ticks
       )

    Попробуем собрать тиковые данные из MetaTrader 5.

    def fetch_ticks(start_datetime: datetime, end_datetime: datetime, symbol: str):
    
        ticks = mt5.copy_ticks_range(symbol, start_datetime, end_datetime, mt5.COPY_TICKS_ALL)
        
        print(f"Fetched {len(ticks)} ticks for {symbol} from {start_datetime} to {end_datetime}")
        print(ticks[:5])  # Print first 5 ticks for inspection
        
        return ticks

    Пример.

    import MetaTrader5 as mt5
    from datetime import datetime, timezone
    
    if __name__ == "__main__":
        if not mt5.initialize():
            print(f"Failed to Initialize MetaTrader5. Error = {mt5.last_error()}")
            mt5.shutdown()
            quit()
        
        symbol = "EURUSD"
        start_dt = datetime(2025, 1, 1, 0, 0, tzinfo=timezone.utc)
        end_dt = datetime(2025, 12, 1, 1, 0, tzinfo=timezone.utc)
        
        fetch_ticks(start_dt, end_dt, symbol)
    

    Вывод.

    Fetched 2814462 ticks for EURUSD from 2025-01-01 00:00:00+00:00 to 2025-12-01 01:00:00+00:00
    [(1758499200, 1.17403, 1.17603, 0., 0, 1758499200161, 134, 0.)
     (1758499247, 1.17405, 1.17605, 0., 0, 1758499247468, 134, 0.)
     (1758499500, 1.17346, 1.17546, 0., 0, 1758499500116, 134, 0.)
     (1758499505, 1.173  , 1.175  , 0., 0, 1758499505869, 134, 0.)
     (1758499510, 1.17307, 1.17487, 0., 0, 1758499510079, 134, 0.)]

    Как видно, всего за 11 месяцев нам удалось получить 2,8 миллиона тиковых записей. Мы также можем проверить их размер в мегабайтах. Это даст приблизительную оценку того, сколько памяти RAM потребляет один такой запрос тиков.

        # calculate tick array size in megabytes
        size_in_bytes = ticks.nbytes
        size_in_mb = size_in_bytes / (1024 * 1024)
        print(f"Tick array size: {size_in_mb:.2f} MB")
    

    Вывод.

    Tick array size: 161.04 MB

    Как видно, данные всего за 11 месяцев занимают около 0,1 ГБ. Теперь представьте, что в нашем симуляторе, то есть тестере стратегий, пользователь решит протестировать мультивалютного бота по 12 символам за 20 лет. Насколько это нагрузит память и общую производительность?

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

    Polars DataFrames — одно из лучших решений для подобных ситуаций.

    Polars прост в использовании и очень быстр; его streaming API позволяет разработчикам эффективно обрабатывать большие наборы данных, включая наборы данных, превышающие объём доступной памяти, например 100 ГБ и больше.

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

    def ticks_to_polars(ticks):
        return pl.DataFrame({
            "time": ticks["time"],
            "bid": ticks["bid"],
            "ask": ticks["ask"],
            "last": ticks["last"],
            "volume": ticks["volume"],
            "time_msc": ticks["time_msc"],
            "flags": ticks["flags"],
            "volume_real": ticks["volume_real"],
        })
        
    def fetch_historical_ticks(start_datetime: datetime, 
                               end_datetime: datetime,
                               symbol: str):
    
        # first of all, we have to ensure the symbol is valid and can be used for requesting data
        if not utils.ensure_symbol(symbol=symbol):
            print(f"Symbol {symbol} not available")
            return
    
        current = start_datetime.replace(day=1, hour=0, minute=0, second=0)
    
        while True:
            month_start, month_end = utils.month_bounds(current)
    
            # Cap last month to end_date
            if (
                month_start.year == end_datetime.year and
                month_start.month == end_datetime.month
            ):
                month_end = end_datetime
    
            # Stop condition
            if month_start > end_datetime:
                break
    
            print(f"Processing ticks {month_start:%Y-%m-%d} -> {month_end:%Y-%m-%d}")
    
            # --- fetch data here ---
            ticks = mt5.copy_ticks_range(
                symbol,
                month_start,
                month_end, 
                mt5.COPY_TICKS_ALL
            )
    
            if ticks is None or len(ticks) == 0:
                
                config.simulator_logger.critical(f"Failed to Get ticks. Error = {mt5.last_error()}")
                current = (month_start + timedelta(days=32)).replace(day=1) # Advance to next month safely
                
                continue
            
            df = ticks_to_polars(ticks)
    
            df = df.with_columns([
                pl.from_epoch("time", time_unit="s").dt.replace_time_zone("utc").alias("time")
            ])
    
            df = df.with_columns([
                pl.col("time").dt.year().alias("year"),
                pl.col("time").dt.month().alias("month"),
            ])
            
            df.write_parquet(
                os.path.join(config.TICKS_HISTORY_DIR, symbol),
                partition_by=["year", "month"],
                mkdir=True
            )
            
            if config.debug:
               print(df.head(-10))
            
            # Advance to next month safely
            current = (month_start + timedelta(days=32)).replace(day=1)

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

          df.write_parquet(
                os.path.join(config.TICKS_HISTORY_DIR, symbol),
                partition_by=["year", "month"],
                mkdir=True
            )

    Выведем данные, чтобы посмотреть, что содержит объект DataFrame.

    print(df.head(-10))  # optional, see what data looks like

    Вывод.

    2025-12-24 16:41:44,138 | CRITICAL | simulator.log20251224 | fetch_historical_ticks 52 --> Failed to Get ticks. Error = (1, 'Success')
    Processing ticks 2025-07-01 -> 2025-07-31
    2025-12-24 16:41:44,139 | CRITICAL | simulator.log20251224 | fetch_historical_ticks 52 --> Failed to Get ticks. Error = (1, 'Success')
    Processing ticks 2025-08-01 -> 2025-08-31
    2025-12-24 16:41:44,140 | CRITICAL | simulator.log20251224 | fetch_historical_ticks 52 --> Failed to Get ticks. Error = (1, 'Success')
    Processing ticks 2025-09-01 -> 2025-09-30
    shape: (434_916, 10)
    ┌─────────────────────────┬─────────┬─────────┬──────┬───┬───────┬─────────────┬──────┬───────┐
    │ time                    ┆ bid     ┆ ask     ┆ last ┆ … ┆ flags ┆ volume_real ┆ year ┆ month │
    │ ---                     ┆ ---     ┆ ---     ┆ ---  ┆   ┆ ---   ┆ ---         ┆ ---  ┆ ---   │
    │ datetime[μs, UTC]       ┆ f64     ┆ f64     ┆ f64  ┆   ┆ u32   ┆ f64         ┆ i32  ┆ i8    │
    ╞═════════════════════════╪═════════╪═════════╪══════╪═══╪═══════╪═════════════╪══════╪═══════╡
    │ 2025-09-22 00:00:00 UTC ┆ 1.174031.176030.0  ┆ … ┆ 134   ┆ 0.0         ┆ 20259     │
    │ 2025-09-22 00:00:47 UTC ┆ 1.174051.176050.0  ┆ … ┆ 134   ┆ 0.0         ┆ 20259     │
    │ 2025-09-22 00:05:00 UTC ┆ 1.173461.175460.0  ┆ … ┆ 134   ┆ 0.0         ┆ 20259     │
    │ 2025-09-22 00:05:05 UTC ┆ 1.173   ┆ 1.175   ┆ 0.0  ┆ … ┆ 134   ┆ 0.0         ┆ 20259     │
    │ 2025-09-22 00:05:10 UTC ┆ 1.173071.174870.0  ┆ … ┆ 134   ┆ 0.0         ┆ 20259     │
    │ …                       ┆ …       ┆ …       ┆ …    ┆ … ┆ …     ┆ …           ┆ …    ┆ …     │
    │ 2025-09-30 23:58:44 UTC ┆ 1.173351.173430.0  ┆ … ┆ 4     ┆ 0.0         ┆ 20259     │
    │ 2025-09-30 23:58:45 UTC ┆ 1.173351.173420.0  ┆ … ┆ 4     ┆ 0.0         ┆ 20259     │
    │ 2025-09-30 23:58:46 UTC ┆ 1.173351.173430.0  ┆ … ┆ 4     ┆ 0.0         ┆ 20259     │
    │ 2025-09-30 23:58:47 UTC ┆ 1.173351.173420.0  ┆ … ┆ 4     ┆ 0.0         ┆ 20259     │
    │ 2025-09-30 23:58:50 UTC ┆ 1.173341.1734  ┆ 0.0  ┆ … ┆ 134   ┆ 0.0         ┆ 20259     │
    └─────────────────────────┴─────────┴─────────┴──────┴───┴───────┴─────────────┴──────┴───────┘
    Processing ticks 2025-10-01 -> 2025-10-31
    shape: (1_401_674, 10)
    ┌─────────────────────────┬─────────┬─────────┬──────┬───┬───────┬─────────────┬──────┬───────┐
    │ time                    ┆ bid     ┆ ask     ┆ last ┆ … ┆ flags ┆ volume_real ┆ year ┆ month │
    │ 2025-10-01 00:00:01 UTC ┆ 1.173371.175060.0  ┆ … ┆ 134   ┆ 0.0         ┆ 202510    │
    │ 2025-10-01 00:00:02 UTC ┆ 1.173371.174020.0  ┆ … ┆ 4     ┆ 0.0         ┆ 202510    │
    │ 2025-10-01 00:00:02 UTC ┆ 1.173371.173890.0  ┆ … ┆ 4     ┆ 0.0         ┆ 202510    │
    │ …                       ┆ …       ┆ …       ┆ …    ┆ … ┆ …     ┆ …           ┆ …    ┆ …     │
    │ 2025-10-31 23:56:43 UTC ┆ 1.153681.153680.0  ┆ … ┆ 134   ┆ 0.0         ┆ 202510    │
    │ 2025-10-31 23:56:52 UTC ┆ 1.153691.153690.0  ┆ … ┆ 134   ┆ 0.0         ┆ 202510    │
    │ 2025-10-31 23:56:52 UTC ┆ 1.153711.153710.0  ┆ … ┆ 134   ┆ 0.0         ┆ 202510    │
    │ 2025-10-31 23:56:53 UTC ┆ 1.1537  ┆ 1.1537  ┆ 0.0  ┆ … ┆ 134   ┆ 0.0         ┆ 202510    │
    │ 2025-10-31 23:56:53 UTC ┆ 1.153711.153710.0  ┆ … ┆ 134   ┆ 0.0         ┆ 202510    │
    └─────────────────────────┴─────────┴─────────┴──────┴───┴───────┴─────────────┴──────┴───────┘
    Processing ticks 2025-11-01 -> 2025-11-30
    shape: (976_714, 10)
    ┌─────────────────────────┬─────────┬─────────┬──────┬───┬───────┬─────────────┬──────┬───────┐
    │ time                    ┆ bid     ┆ ask     ┆ last ┆ … ┆ flags ┆ volume_real ┆ year ┆ month │
    │ ---                     ┆ ---     ┆ ---     ┆ ---  ┆   ┆ ---   ┆ ---         ┆ ---  ┆ ---   │
    │ datetime[μs, UTC]       ┆ f64     ┆ f64     ┆ f64  ┆   ┆ u32   ┆ f64         ┆ i32  ┆ i8    │
    ╞═════════════════════════╪═════════╪═════════╪══════╪═══╪═══════╪═════════════╪══════╪═══════╡
    │ 2025-11-03 00:00:00 UTC ┆ 1.1528  ┆ 1.153650.0  ┆ … ┆ 134   ┆ 0.0         ┆ 202511    │
    │ 2025-11-03 00:01:00 UTC ┆ 1.1528  ┆ 1.153650.0  ┆ … ┆ 130   ┆ 0.0         ┆ 202511    │
    │ 2025-11-03 00:01:00 UTC ┆ 1.1528  ┆ 1.153650.0  ┆ … ┆ 4     ┆ 0.0         ┆ 202511    │
    │ 2025-11-03 00:01:21 UTC ┆ 1.152951.153650.0  ┆ … ┆ 130   ┆ 0.0         ┆ 202511    │
    │ 2025-11-03 00:01:25 UTC ┆ 1.152821.153650.0  ┆ … ┆ 130   ┆ 0.0         ┆ 202511    │
    │ …                       ┆ …       ┆ …       ┆ …    ┆ … ┆ …     ┆ …           ┆ …    ┆ …     │
    │ 2025-11-28 23:55:12 UTC ┆ 1.159481.160180.0  ┆ … ┆ 134   ┆ 0.0         ┆ 202511    │
    │ 2025-11-28 23:55:13 UTC ┆ 1.159551.160170.0  ┆ … ┆ 134   ┆ 0.0         ┆ 202511    │
    │ 2025-11-28 23:55:36 UTC ┆ 1.159481.160180.0  ┆ … ┆ 134   ┆ 0.0         ┆ 202511    │
    │ 2025-11-28 23:55:37 UTC ┆ 1.159531.160170.0  ┆ … ┆ 134   ┆ 0.0         ┆ 202511    │
    │ 2025-11-28 23:55:54 UTC ┆ 1.159541.160240.0  ┆ … ┆ 134   ┆ 0.0         ┆ 202511    │
    │ time                    ┆ bid     ┆ ask     ┆ last ┆ … ┆ flags ┆ volume_real ┆ year ┆ month │
    │ ---                     ┆ ---     ┆ ---     ┆ ---  ┆   ┆ ---   ┆ ---         ┆ ---  ┆ ---   │
    │ datetime[μs, UTC]       ┆ f64     ┆ f64     ┆ f64  ┆   ┆ u32   ┆ f64         ┆ i32  ┆ i8    │
    ╞═════════════════════════╪═════════╪═════════╪══════╪═══╪═══════╪═════════════╪══════╪═══════╡
    │ 2025-12-01 00:00:00 UTC ┆ 1.159361.159690.0  ┆ … ┆ 134   ┆ 0.0         ┆ 202512    │
    │ 2025-12-01 00:00:06 UTC ┆ 1.159341.159620.0  ┆ … ┆ 134   ┆ 0.0         ┆ 202512    │
    │ 2025-12-01 00:00:11 UTC ┆ 1.159351.159970.0  ┆ … ┆ 134   ┆ 0.0         ┆ 202512    │
    │ 2025-12-01 00:00:15 UTC ┆ 1.159361.159790.0  ┆ … ┆ 134   ┆ 0.0         ┆ 202512    │
    │ 2025-12-01 00:00:21 UTC ┆ 1.159361.159640.0  ┆ … ┆ 4     ┆ 0.0         ┆ 202512    │
    │ …                       ┆ …       ┆ …       ┆ …    ┆ … ┆ …     ┆ …           ┆ …    ┆ …     │
    │ 2025-12-01 00:59:57 UTC ┆ 1.159641.160050.0  ┆ … ┆ 4     ┆ 0.0         ┆ 202512    │
    │ 2025-12-01 00:59:57 UTC ┆ 1.159721.160120.0  ┆ … ┆ 134   ┆ 0.0         ┆ 202512    │
    │ 2025-12-01 00:59:57 UTC ┆ 1.159671.160050.0  ┆ … ┆ 134   ┆ 0.0         ┆ 202512    │
    │ 2025-12-01 00:59:57 UTC ┆ 1.159711.160090.0  ┆ … ┆ 134   ┆ 0.0         ┆ 202512    │
    │ 2025-12-01 00:59:57 UTC ┆ 1.159651.160050.0  ┆ … ┆ 134   ┆ 0.0         ┆ 202512    │
    └─────────────────────────┴─────────┴─────────┴──────┴───┴───────┴─────────────┴──────┴───────┘
    January 2024:
     shape: (0, 10)
    ┌───────────────────┬─────┬─────┬──────┬───┬───────┬─────────────┬──────┬───────┐
    │ time              ┆ bid ┆ ask ┆ last ┆ … ┆ flags ┆ volume_real ┆ year ┆ month │
    │ ---               ┆ --- ┆ --- ┆ ---  ┆   ┆ ---   ┆ ---         ┆ ---  ┆ ---   │
    │ datetime[μs, UTC] ┆ f64 ┆ f64 ┆ f64  ┆   ┆ u32   ┆ f64         ┆ i32  ┆ i8    │
    ╞═══════════════════╪═════╪═════╪══════╪═══╪═══════╪═════════════╪══════╪═══════╡
    └───────────────────┴─────┴─────┴──────┴───┴───────┴─────────────┴──────┴───────┘
    shape: (1, 2)
    ┌───────────────────┬───────────────────┐
    │ time_min          ┆ time_max          │
    │ ---               ┆ ---               │
    │ datetime[μs, UTC] ┆ datetime[μs, UTC] │
    ╞═══════════════════╪═══════════════════╡
    │ null              ┆ null              │
    └───────────────────┴───────────────────┘

    Одна из самых удобных особенностей метода Polars под названием write_parquet заключается в том, что при передаче значения в аргумент partition_by он использует полученные столбцы как группы и сохраняет данные в отдельные подпапки.

    После сбора тиков по двум инструментам.

    if __name__ == "__main__":
        
        if not mt5.initialize():
            print(f"Failed to Initialize MetaTrader5. Error = {mt5.last_error()}")
            mt5.shutdown()
            quit()
        
        symbol = "EURUSD"
        start_dt = datetime(2025, 1, 1, 0, 0, tzinfo=timezone.utc)
        end_dt = datetime(2025, 12, 1, 1, 0, tzinfo=timezone.utc)
        
        fetch_historical_ticks(start_datetime=start_dt, end_datetime=end_dt, symbol=symbol)
        fetch_historical_ticks(start_datetime=start_dt, end_datetime=end_dt, symbol= "GBPUSD")
        
        path = os.path.join(config.TICKS_HISTORY_DIR, symbol)
        lf = pl.scan_parquet(path)
    
        jan_2024 = (
            lf
            .filter(
                (pl.col("year") == 2024) &
                (pl.col("month") == 1)
            )
            .collect(engine="streaming")
        )
    
        print("January 2024:\n", jan_2024.head(-10))
        print(
            jan_2024.select([
                pl.col("time").min().alias("time_min"),
                pl.col("time").max().alias("time_max")
            ])
        )
        
        mt5.shutdown()

    Ниже показано, как выглядят выходные папки.

    (venv) c:\Users\Omega Joctan\OneDrive\Documents\PyMetaTester>tree History
    Folder PATH listing
    Volume serial number is 2CFE-3A78
    C:\USERS\OMEGA JOCTAN\ONEDRIVE\DOCUMENTS\PYMETATESTER\HISTORY
    ├───Bars
    │   ├───EURUSD
    │   │   └───M5
    └───Ticks
        ├───EURUSD
        │   └───year=2025
        │       ├───month=10
        │       ├───month=11
        │       ├───month=12
        │       └───month=9
        └───GBPUSD
            └───year=2025
                ├───month=10
                ├───month=11
                ├───month=12
                └───month=9

    К сожалению, мне не удалось получить все тиковые данные, которые я запрашивал, то есть с 1 января по 1 декабря 2025 года. Похоже, что нельзя получить больше тиков, чем доступно в вашем терминале MetaTrader 5. В данном случае у моего брокера было лишь несколько месяцев тиковых данных, и именно их я постоянно получал.

    From: C:\Users\Omega\AppData\Roaming\MetaQuotes\Terminal\010E047102812FC0C18890992854220E\bases\<broker name>\ticks\EURUSD



    Обработка исторических баров MetaTrader 5

    В отличие от тиков, бары основаны на таймфреймах. С барами работать проще, чем с тиками. Аналогично тому, как мы собирали тики, нам нужно похожим образом собрать данные баров.

    Сначала необходимо убедиться, что символ доступен, и выбрать его в MarketWatch перед запросом его баров.

    Внутри utils.py:

    def ensure_symbol(symbol: str) -> bool:
        info = mt5.symbol_info(symbol)
        if info is None:
            print(f"Symbol {symbol} not found")
            return False
    
        if not info.visible:
            if not mt5.symbol_select(symbol, True):
                print(f"Failed to select symbol {symbol}")
                return False
        return True

    Затем мы собираем данные, начиная с первого и заканчивая последним днём месяца.

    def fetch_historical_bars(symbol: str, 
                              timeframe: int,
                              start_datetime: datetime,
                              end_datetime: datetime):
        """
        Fetch historical bar data for a given symbol and timeframe, forward in time.
        Saves data to a single Parquet file in append mode.
        """
        
        if not utils.ensure_symbol(symbol=symbol):
            print(f"Symbol {symbol} not available")
            return
        
        current = start_datetime.replace(day=1, hour=0, minute=0, second=0)
    
        while True:
            month_start, month_end = utils.month_bounds(current)
    
            # Cap last month to end_date
            if (
                month_start.year == end_datetime.year and
                month_start.month == end_datetime.month
            ):
                month_end = end_datetime
    
            # Stop condition
            if month_start > end_datetime:
                break
    
            print(f"Processing {month_start:%Y-%m-%d} -> {month_end:%Y-%m-%d}")
    
            # --- fetch data here ---
            rates = mt5.copy_rates_range(
                symbol,
                timeframe,
                month_start,
                month_end
            )
    
            if rates is None and len(rates)==0:
                config.simulator_logger.warning(f"Failed to Get bars from MetaTrader5")
                current = (month_start + timedelta(days=32)).replace(day=1) # Advance to next month safely
                continue
                
            df = bars_to_polars(rates)

    Мы сохраняем данные баров в соответствующие Parquet-файлы, разделённые по месяцам и годам в виде подпапок.

    df = df.with_columns([
        pl.from_epoch("time", time_unit="s").dt.replace_time_zone("utc").alias("time")
    ])
    
    df = df.with_columns([
        pl.col("time").dt.year().alias("year"),
        pl.col("time").dt.month().alias("month"),
    ])
            
    tf_name = utils.TIMEFRAMES_REV[timeframe]
    df.write_parquet(
        os.path.join(config.BARS_HISTORY_DIR, symbol, tf_name),
        partition_by=["year", "month"],
        mkdir=True
    )
    
    if config.is_debug:
        print(df.head(-10))
                
    # Advance to next month safely
    current = (month_start + timedelta(days=32)).replace(day=1)

    Например, бары, собранные по трём символам за 10 месяцев.

    if __name__ == "__main__":
        
        if not mt5.initialize():
            print(f"Failed to Initialize MetaTrader5. Error = {mt5.last_error()}")
            mt5.shutdown()
            quit()
        
        start_date = datetime(2022, 1, 1, tzinfo=timezone.utc)
        end_date = datetime(2025, 1, 10, tzinfo=timezone.utc)
        
        fetch_historical_bars("XAUUSD", mt5.TIMEFRAME_M1, start_date, end_date)
        fetch_historical_bars("EURUSD", mt5.TIMEFRAME_H1, start_date, end_date)
        fetch_historical_bars("GBPUSD", mt5.TIMEFRAME_M5, start_date, end_date)
        
        # read polaris dataframe and print the head for both symbols
    
        symbol = "GBPUSD"
        timeframe = utils.TIMEFRAMES_REV[mt5.TIMEFRAME_M5]
        
        path = os.path.join(config.BARS_HISTORY_DIR, symbol, timeframe)
        
        lf = pl.scan_parquet(path)
    
        jan_2024 = (
            lf
            .filter(
                (pl.col("year") == 2024) &
                (pl.col("month") == 1)
            )
            .collect(engine="streaming")
        )
    
        print("January 2024:\n", jan_2024.head(-10))
        print(
            jan_2024.select([
                pl.col("time").min().alias("time_min"),
                pl.col("time").max().alias("time_max")
            ])
        )
        
        mt5.shutdown()
    

    Вывод.

    (venv) c:\Users\Omega Joctan\OneDrive\Documents\PyMetaTester>tree History
    Folder PATH listing
    Volume serial number is 2CFE-3A78
    C:\USERS\OMEGA JOCTAN\ONEDRIVE\DOCUMENTS\PYMETATESTER\HISTORY
    ├───Bars
    │   ├───EURUSD
    │   │   ├───H1
    │   │   │   ├───year=2022
    │   │   │   │   ├───month=1
    │   │   │   │   ├───month=10
    │   │   │   │   ├───month=11
    │   │   │   │   ├───month=12
    │   │   │   │   ├───month=2
    │   │   │   │   ├───month=3
    │   │   │   │   ├───month=4
    │   │   │   │   ├───month=5
    │   │   │   │   ├───month=6
    │   │   │   │   ├───month=7
    │   │   │   │   ├───month=8
    │   │   │   │   └───month=9
    │   │   │   ├───year=2023
    │   │   │   │   ├───month=1
    │   │   │   │   ├───month=10
    │   │   │   │   ├───month=11
    │   │   │   │   ├───month=12
    │   │   │   │   ├───month=2
    │   │   │   │   ├───month=3
    │   │   │   │   ├───month=4
    │   │   │   │   ├───month=5
    │   │   │   │   ├───month=6
    │   │   │   │   ├───month=7
    │   │   │   │   ├───month=8
    │   │   │   │   └───month=9
    │   │   │   ├───year=2024
    │   │   │   │   ├───month=1
    │   │   │   │   ├───month=10
    │   │   │   │   ├───month=11
    │   │   │   │   ├───month=12
    │   │   │   │   ├───month=2
    │   │   │   │   ├───month=3
    │   │   │   │   ├───month=4
    │   │   │   │   ├───month=5
    │   │   │   │   ├───month=6
    │   │   │   │   ├───month=7
    │   │   │   │   ├───month=8
    │   │   │   │   └───month=9
    │   │   │   └───year=2025
    │   │   │       └───month=1
    │   │   └───M5
    │   │       ├───year=2022
    │   │       │   ├───month=1
    │   │       │   ├───month=10
    │   │       │   ├───month=11
    │   │       │   ├───month=12
    │   │       │   ├───month=2
    │   │       │   ├───month=3
    │   │       │   ├───month=4
    │   │       │   ├───month=5
    │   │       │   ├───month=6
    │   │       │   ├───month=7
    │   │       │   ├───month=8
    │   │       │   └───month=9
    │   │       ├───year=2023
    │   │       │   ├───month=1
    │   │       │   ├───month=10
    │   │       │   ├───month=11
    │   │       │   ├───month=12
    │   │       │   ├───month=2
    │   │       │   ├───month=3
    │   │       │   ├───month=4
    │   │       │   ├───month=5
    │   │       │   ├───month=6
    │   │       │   ├───month=7
    │   │       │   ├───month=8
    │   │       │   └───month=9
    │   │       ├───year=2024
    │   │       │   ├───month=1
    │   │       │   ├───month=10
    │   │       │   ├───month=11
    │   │       │   ├───month=12
    │   │       │   ├───month=2
    │   │       │   ├───month=3
    │   │       │   ├───month=4
    │   │       │   ├───month=5
    │   │       │   ├───month=6
    │   │       │   ├───month=7
    │   │       │   ├───month=8
    │   │       │   └───month=9
    │   │       └───year=2025
    │   │           └───month=1
    └───Ticks
        ├───EURUSD
        │   └───year=2025
        │       ├───month=10
        │       ├───month=11
        │       ├───month=12
        │       └───month=9
        └───GBPUSD
            └───year=2025
                ├───month=10
                ├───month=11
                ├───month=12
                └───month=9


    Перегрузка функций MetaTrader 5

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

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

    Когда IS_TESTER установлен в False, мы делаем обратное — извлекаем такие данные напрямую из MetaTrader 5.

    class Simulator:
        def __init__(self, simulator_name: str, mt5_instance: mt5, deposit: float, leverage: str="1:100"):
            
            #... other variables
    
            self.IS_RUNNING = True # is the simulator running or stopped
            self.IS_TESTER = True # are we on the strategy tester mode or live trading 
            
            self.symbol_info_cache: dict[str, namedtuple] = {}
            
        def Start(self, IS_TESTER: bool) -> bool: # simulator start
            
            self.IS_TESTER = IS_TESTER
        
        def Stop(self): # simulator stopped
            self.IS_RUNNING = False
            pass

    symbol_info_tick

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

    symbol_info_tick(
       symbol      // financial instrument name
    )

    Нам нужна аналогичная функция внутри класса Simulator. Функция должна решать, возвращать тики из MetaTrader 5 или тики внутри симулятора.

        def symbol_info_tick(self, symbol: str) -> namedtuple:
            
            if self.IS_TESTER:
                return self.tick_cache[symbol] 
            
            try:
                tick = self.mt5_instance.symbol_info_tick(symbol)
            except Exception as e:
                self.__GetLogger().warning(f"Failed. MT5 Error = {self.mt5_instance.last_error()}")
                
            return tick

    Внутри класса симулятора у нас есть массив для отслеживания последних тиков.

    В конструкторе класса:

    self.tick_cache: dict[str, namedtuple] = {}

    Однако этот симулятор должен получать эту тиковую информацию. Для такой задачи нам нужна функция.

    def TickUpdate(self, symbol: str, tick: namedtuple):
        self.tick_cache[symbol] = tick

    symbol_info

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

    Сигнатура функции:

    symbol_info(
       symbol      // financial instrument name
    )

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

    После извлечения данных символа из MetaTrader 5 они должны сохраняться в массиве для последующего использования. Это снижает зависимость от MetaTrader 5 и улучшает общую производительность.

    def symbol_info(self, symbol: str) -> namedtuple:    
            
        """Gets data on the specified financial instrument."""
            
        if symbol not in self.symbol_info_cache:
            info = self.mt5_instance.symbol_info(symbol)
            if info is None:
               return None
                
           self.symbol_info_cache[symbol] = info
            
       return self.symbol_info_cache[symbol]

    Массив для временного хранения данных символов определяется аналогично массиву для хранения тиков, рассмотренному выше.

    self.symbol_info_cache: dict[str, namedtuple] = {}

    copy_rates_from

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

    copy_rates_from(
       symbol,       // symbol name
       timeframe,    // timeframe
       date_from,    // initial bar open date
       count         // number of bars
       )

    В аналогичной функции нашего класса мы начинаем с того, что убеждаемся, что заданная начальная дата находится в формате UTC.

    def copy_rates_from(self, symbol: str, timeframe: int, date_from: datetime, count: int) -> np.array:
            
        date_from = utils.ensure_utc(date_from)

    Если пользователь выбрал режим тестера стратегий (IS_TESTER=True), мы получаем данные баров, сохранённые в Parquet-файлах.

    if self.IS_TESTER:    
                
        # instead of getting data from MetaTrader 5, get data stored in our custom directories
                
        path = os.path.join(config.BARS_HISTORY_DIR, symbol, utils.TIMEFRAMES_REV[timeframe])
        lf = pl.scan_parquet(path)
    
        try:
            rates = (
                lf
                .filter(pl.col("time") <= date_from) # get data starting at the given date
                .sort("time", descending=True) 
                .limit(count) # limit the request to some bars
                .select([
                    pl.col("time").dt.epoch("s").cast(pl.Int64).alias("time"),
    
                    pl.col("open"),
                    pl.col("high"),
                    pl.col("low"),
                    pl.col("close"),
                    pl.col("tick_volume"),
                    pl.col("spread"),
                    pl.col("real_volume"),
                ]) # return only what's required 
                .collect(engine="streaming") # the streming engine, doesn't store data in memory
            ).to_dicts()
    
            rates = np.array(rates)[::-1] # reverse an array so it becomes oldest -> newest
                
        except Exception as e:
            config.tester_logger.warn(f"Failed to copy rates {e}")
            return np.array(dict())
    else:
                
        rates = self.mt5_instance.copy_rates_from(symbol, timeframe, date_from, count)
        rates = np.array(self.__mt5_rates_to_dicts(rates))
                
        if rates is None:
            config.simulator_logger.warn(f"Failed to copy rates. MetaTrader 5 error = {self.mt5_instance.last_error()}")
            return np.array(dict())
                
    return rates

    Если переменная класса IS_TESTER установлена в False, мы получаем данные баров напрямую из MetaTrader 5.

    Поскольку MetaTrader 5 возвращает структурированный массив NumPy, преобразуем его в массив NumPy со словарями данных для каждого элемента массива. Это делает формат согласованным с форматом, полученным после преобразования объекта Polars DataFrame.

    def __mt5_rates_to_dicts(self, rates) -> list[dict]:
            
        if rates is None or len(rates) == 0:
            return []
    
        # structured numpy array from MT5
        if rates.dtype.names is not None:
            return [
                {name: r[name].item() if hasattr(r[name], "item") else r[name]
                for name in rates.dtype.names}
                for r in rates
            ]
    

    Пример использования:

    sim = Simulator(simulator_name="MySimulator", mt5_instance=mt5, deposit=1078.30, leverage="1:500")
    
    start = datetime(2025, 1, 1)
    bars = 10
    
    sim.Start(IS_TESTER=True) # start the simulator in the strategy tester mode
    rates = sim.copy_rates_from(symbol="EURUSD", timeframe=mt5.TIMEFRAME_H1, date_from=start, count=bars)
    print("is_tester=true\n", rates)
    
    sim.Start(IS_TESTER=False) # start the simulator in real-time trading
    rates = sim.copy_rates_from(symbol="EURUSD", timeframe=mt5.TIMEFRAME_H1, date_from=start, count=bars)
    
    print("is_tester=false\n",rates)

    Вывод.

    is_tester=true
     [{'time': 1735653600, 'open': 1.04104, 'high': 1.04145, 'low': 1.03913, 'close': 1.03928, 'tick_volume': 2543, 'spread': 0, 'real_volume': 0}
     {'time': 1735657200, 'open': 1.03929, 'high': 1.03973, 'low': 1.03836, 'close': 1.0393, 'tick_volume': 3171, 'spread': 0, 'real_volume': 0}
     {'time': 1735660800, 'open': 1.03931, 'high': 1.03943, 'low': 1.03748, 'close': 1.03759, 'tick_volume': 4073, 'spread': 0, 'real_volume': 0}
     {'time': 1735664400, 'open': 1.03759, 'high': 1.03893, 'low': 1.03527, 'close': 1.03548, 'tick_volume': 5531, 'spread': 0, 'real_volume': 0}
     {'time': 1735668000, 'open': 1.03548, 'high': 1.03614, 'low': 1.0346899999999999, 'close': 1.03504, 'tick_volume': 3918, 'spread': 0, 'real_volume': 0}
     {'time': 1735671600, 'open': 1.03504, 'high': 1.03551, 'low': 1.03442, 'close': 1.03493, 'tick_volume': 3279, 'spread': 0, 'real_volume': 0}
     {'time': 1735675200, 'open': 1.0348600000000001, 'high': 1.03569, 'low': 1.03455, 'close': 1.0352999999999999, 'tick_volume': 2693, 'spread': 0, 'real_volume': 0}
     {'time': 1735678800, 'open': 1.0352999999999999, 'high': 1.03647, 'low': 1.03516, 'close': 1.03548, 'tick_volume': 1840, 'spread': 0, 'real_volume': 0}
     {'time': 1735682400, 'open': 1.03549, 'high': 1.03633, 'low': 1.03546, 'close': 1.03586, 'tick_volume': 1192, 'spread': 0, 'real_volume': 0}
     {'time': 1735686000, 'open': 1.03586, 'high': 1.0361, 'low': 1.03527, 'close': 1.03527, 'tick_volume': 975, 'spread': 0, 'real_volume': 0}]
    is_tester=false
     [{'time': 1735653600, 'open': 1.04104, 'high': 1.04145, 'low': 1.03913, 'close': 1.03928, 'tick_volume': 2543, 'spread': 0, 'real_volume': 0}
     {'time': 1735657200, 'open': 1.03929, 'high': 1.03973, 'low': 1.03836, 'close': 1.0393, 'tick_volume': 3171, 'spread': 0, 'real_volume': 0}
     {'time': 1735660800, 'open': 1.03931, 'high': 1.03943, 'low': 1.03748, 'close': 1.03759, 'tick_volume': 4073, 'spread': 0, 'real_volume': 0}
     {'time': 1735664400, 'open': 1.03759, 'high': 1.03893, 'low': 1.03527, 'close': 1.03548, 'tick_volume': 5531, 'spread': 0, 'real_volume': 0}
     {'time': 1735668000, 'open': 1.03548, 'high': 1.03614, 'low': 1.0346899999999999, 'close': 1.03504, 'tick_volume': 3918, 'spread': 0, 'real_volume': 0}
     {'time': 1735671600, 'open': 1.03504, 'high': 1.03551, 'low': 1.03442, 'close': 1.03493, 'tick_volume': 3279, 'spread': 0, 'real_volume': 0}
     {'time': 1735675200, 'open': 1.0348600000000001, 'high': 1.03569, 'low': 1.03455, 'close': 1.0352999999999999, 'tick_volume': 2693, 'spread': 0, 'real_volume': 0}
     {'time': 1735678800, 'open': 1.0352999999999999, 'high': 1.03647, 'low': 1.03516, 'close': 1.03548, 'tick_volume': 1840, 'spread': 0, 'real_volume': 0}
     {'time': 1735682400, 'open': 1.03549, 'high': 1.03633, 'low': 1.03546, 'close': 1.03586, 'tick_volume': 1192, 'spread': 0, 'real_volume': 0}
     {'time': 1735686000, 'open': 1.03586, 'high': 1.0361, 'low': 1.03527, 'close': 1.03527, 'tick_volume': 975, 'spread': 0, 'real_volume': 0}]

    copy_rates_from_pos

    Согласно документации, эта функция получает бары из терминала MetaTrader 5, начиная с указанного индекса.

    В индексе 0 находится текущий бар, а бар с наибольшим индексом является самым старым баром в терминале.

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

    Поскольку бар с индексом 0 всегда является текущим баром, это означает, что текущая функция должна знать время текущего тика. При запуске симулятора в так называемом тестере стратегий мы наследуем функцию copy_rates_from, которая принимает время начальной даты.

    Мы передаём ей начальную дату:

    текущее время + текущий таймфрейм в секундах * количество баров, запрошенное пользователем.

        def copy_rates_from_pos(self, symbol: str, timeframe: int, start_pos: int, count: int) -> np.array:
    
            if self.tick is None or self.tick.time is None:
                self.__GetLogger().critical("Time information not found in the ticker, call the function 'TickUpdate' giving it the latest tick information")
                now = datetime.now(tz=timezone.utc)
            else:
                now = self.tick.time
            
            if self.IS_TESTER:    
                rates = self.copy_rates_from(symbol=symbol, 
                                            timeframe=timeframe,
                                            date_from=now+timedelta(seconds=utils.PeriodSeconds(timeframe)*start_pos),
                                            count=count)
            
            else:
                
                rates = self.mt5_instance.copy_rates_from_pos(symbol, timeframe, start_pos, count)
                rates = np.array(self.__mt5_rates_to_dicts(rates))
                
                if rates is None:
                    self.__GetLogger().warning(f"Failed to copy rates. MetaTrader 5 error = {self.mt5_instance.last_error()}")
                    return np.array(dict())
                
            return rates
            

    Когда IS_TESTER=False, то есть система работает в реальном времени, симулятор получает бары напрямую из терминала MetaTrader 5.

    Пример использования:

    sim = Simulator(simulator_name="MySimulator", mt5_instance=mt5, deposit=1078.30, leverage="1:500")
    
    start = datetime(2025, 1, 1)
    bars = 10
    symbol = "EURUSD"
    timeframe = mt5.TIMEFRAME_H1
    
    sim.Start(IS_TESTER=True)
    rates = sim.copy_rates_from_pos(symbol=symbol, timeframe=timeframe, start_pos=0, count=bars)
    
    print("is_tester=true\n", rates)
    
    sim.Start(IS_TESTER=False) # start the simulator in real-time trading
    rates = sim.copy_rates_from_pos(symbol=symbol, timeframe=timeframe, start_pos=0, count=bars)
    
    print("is_tester=false\n",rates)

    Вывод.

    (venv) C:\Users\Omega Joctan\OneDrive\Documents\PyMetaTester>python test.py
    2025-12-25 12:42:33,366 | CRITICAL | tester |  copy_rates_from_pos 221 --> Time information not found in the ticker, call the function 'TickUpdate' giving it the latest tick information
    is_tester=true
     [{'time': 1766584800, 'open': 1.17927, 'high': 1.17932, 'low': 1.1784, 'close': 1.17843, 'tick_volume': 1983, 'spread': 0, 'real_volume': 0}
     {'time': 1766588400, 'open': 1.17843, 'high': 1.17909, 'low': 1.17838, 'close': 1.17853, 'tick_volume': 2783, 'spread': 0, 'real_volume': 0}
     {'time': 1766592000, 'open': 1.17849, 'high': 1.17869, 'low': 1.17773, 'close': 1.17807, 'tick_volume': 2690, 'spread': 0, 'real_volume': 0}
     {'time': 1766595600, 'open': 1.17804, 'high': 1.17825, 'low': 1.17754, 'close': 1.17781, 'tick_volume': 2834, 'spread': 0, 'real_volume': 0}
     {'time': 1766599200, 'open': 1.17781, 'high': 1.1781, 'low': 1.17732, 'close': 1.17795, 'tick_volume': 2354, 'spread': 0, 'real_volume': 0}
     {'time': 1766602800, 'open': 1.17794, 'high': 1.17832, 'low': 1.17726, 'close': 1.17766, 'tick_volume': 1424, 'spread': 0, 'real_volume': 0}
     {'time': 1766606400, 'open': 1.17764, 'high': 1.17798, 'low': 1.17744, 'close': 1.17788, 'tick_volume': 1105, 'spread': 0, 'real_volume': 0}
     {'time': 1766610000, 'open': 1.17788, 'high': 1.1782, 'low': 1.17787, 'close': 1.17817, 'tick_volume': 654, 'spread': 0, 'real_volume': 0}
     {'time': 1766613600, 'open': 1.17817, 'high': 1.17819, 'low': 1.1779, 'close': 1.1779600000000001, 'tick_volume': 608, 'spread': 0, 'real_volume': 0}
     {'time': 1766617200, 'open': 1.1779600000000001, 'high': 1.17797, 'low': 1.17761, 'close': 1.17768, 'tick_volume': 1165, 'spread': 0, 'real_volume': 0}]
    2025-12-25 12:42:33,394 | CRITICAL | simulator |  copy_rates_from_pos 221 --> Time information not found in the ticker, call the function 'TickUpdate' giving it the latest tick information
    is_tester=false
     [{'time': 1766584800, 'open': 1.17927, 'high': 1.17932, 'low': 1.1784, 'close': 1.17843, 'tick_volume': 1983, 'spread': 0, 'real_volume': 0}
     {'time': 1766588400, 'open': 1.17843, 'high': 1.17909, 'low': 1.17838, 'close': 1.17853, 'tick_volume': 2783, 'spread': 0, 'real_volume': 0}
     {'time': 1766592000, 'open': 1.17849, 'high': 1.17869, 'low': 1.17773, 'close': 1.17807, 'tick_volume': 2690, 'spread': 0, 'real_volume': 0}
     {'time': 1766595600, 'open': 1.17804, 'high': 1.17825, 'low': 1.17754, 'close': 1.17781, 'tick_volume': 2834, 'spread': 0, 'real_volume': 0}
     {'time': 1766599200, 'open': 1.17781, 'high': 1.1781, 'low': 1.17732, 'close': 1.17795, 'tick_volume': 2354, 'spread': 0, 'real_volume': 0}
     {'time': 1766602800, 'open': 1.17794, 'high': 1.17832, 'low': 1.17726, 'close': 1.17766, 'tick_volume': 1424, 'spread': 0, 'real_volume': 0}
     {'time': 1766606400, 'open': 1.17764, 'high': 1.17798, 'low': 1.17744, 'close': 1.17788, 'tick_volume': 1105, 'spread': 0, 'real_volume': 0}
     {'time': 1766610000, 'open': 1.17788, 'high': 1.1782, 'low': 1.17787, 'close': 1.17817, 'tick_volume': 654, 'spread': 0, 'real_volume': 0}
     {'time': 1766613600, 'open': 1.17817, 'high': 1.17819, 'low': 1.1779, 'close': 1.1779600000000001, 'tick_volume': 608, 'spread': 0, 'real_volume': 0}
     {'time': 1766617200, 'open': 1.1779600000000001, 'high': 1.17797, 'low': 1.17761, 'close': 1.17768, 'tick_volume': 1165, 'spread': 0, 'real_volume': 0}]
    

    copy_rates_range

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

    copy_rates_range(
       symbol,       // symbol name
       timeframe,    // timeframe
       date_from,    // date the bars are requested from
       date_to       // date, up to which the bars are requested
       )

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

        def copy_rates_range(self, symbol: str, timeframe: int, date_from: datetime, date_to: datetime):
            
            date_from = utils.ensure_utc(date_from)
            date_to = utils.ensure_utc(date_to)
            
            if self.IS_TESTER:    
                
                # instead of getting data from MetaTrader 5, get data stored in our custom directories
                
                path = os.path.join(config.BARS_HISTORY_DIR, symbol, utils.TIMEFRAMES_REV[timeframe])
                lf = pl.scan_parquet(path)
    
                try:
                    rates = (
                        lf
                        .filter(
                                (pl.col("time") >= pl.lit(date_from)) &
                                (pl.col("time") <= pl.lit(date_to))
                            ) # get bars between date_from and date_to
                        .sort("time", descending=True) 
                        .select([
                            pl.col("time").dt.epoch("s").cast(pl.Int64).alias("time"),
    
                            pl.col("open"),
                            pl.col("high"),
                            pl.col("low"),
                            pl.col("close"),
                            pl.col("tick_volume"),
                            pl.col("spread"),
                            pl.col("real_volume"),
                        ]) # return only what's required 
                        .collect(engine="streaming") # the streming engine, doesn't store data in memory
                    ).to_dicts()
    
                    rates = np.array(rates)[::-1] # reverse an array so it becomes oldest -> newest
                
                except Exception as e:
                    self.__GetLogger().warning(f"Failed to copy rates {e}")
                    return np.array(dict())
            else:
                
                rates = self.mt5_instance.copy_rates_range(symbol, timeframe, date_from, date_to)
                rates = np.array(self.__mt5_rates_to_dicts(rates))
                
                if rates is None:
                    self.__GetLogger().warning(f"Failed to copy rates. MetaTrader 5 error = {self.mt5_instance.last_error()}")
                    return np.array(dict())
                
            return rates
    

    copy_ticks_from

    Согласно документации, эта функция получает тики из терминала MetaTrader 5, начиная с указанной даты.

    copy_ticks_from(
       symbol,       // symbol name
       date_from,    // date the ticks are requested from
       count,        // number of requested ticks
       flags         // combination of flags defining the type of requested ticks
       )

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

        def copy_ticks_from(self, symbol: str, date_from: datetime, count: int, flags: int=mt5.COPY_TICKS_ALL) -> np.array:
            
            date_from = utils.ensure_utc(date_from)
            flag_mask = self.__tick_flag_mask(flags)
    
            if self.IS_TESTER:    
                
                path = os.path.join(config.TICKS_HISTORY_DIR, symbol)
                lf = pl.scan_parquet(path)
    
                try:
                    ticks = (
                        lf
                        .filter(pl.col("time") >= pl.lit(date_from)) # get data starting at the given date
                        .filter((pl.col("flags") & flag_mask) != 0)
                        .sort(
                            ["time", "time_msc"],
                            descending=[False, False]
                        )
                        .limit(count) # limit the request to a specified number of ticks
                        .select([
                            pl.col("time").dt.epoch("s").cast(pl.Int64).alias("time"),
    
                            pl.col("bid"),
                            pl.col("ask"),
                            pl.col("last"),
                            pl.col("volume"),
                            pl.col("time_msc"),
                            pl.col("flags"),
                            pl.col("volume_real"),
                        ]) 
                        .collect(engine="streaming") # the streming engine, doesn't store data in memory
                    ).to_dicts()
    
                    ticks = np.array(ticks)
                
                except Exception as e:
                    self.__GetLogger().warning(f"Failed to copy ticks {e}")
                    return np.array(dict())
            else:
                
                ticks = self.mt5_instance.copy_ticks_from(symbol, date_from, count, flags)
                ticks = np.array(self.__mt5_data_to_dicts(ticks))
                
                if ticks is None:
                    self.__GetLogger().warning(f"Failed to copy ticks. MetaTrader 5 error = {self.mt5_instance.last_error()}")
                    return np.array(dict())
                
            return ticks

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

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

    Флаг определяет тип запрашиваемых тиков.

    Значения флагов описаны в перечислении  COPY_TICKS 

    ID

    Описание

    COPY_TICKS_ALL

    все тики

    COPY_TICKS_INFO

    тики, содержащие изменения цены Bid и/или Ask

    COPY_TICKS_TRADE

    тики, содержащие изменения Last и/или Volume


    TICK_FLAG определяет возможные флаги для тиков. Эти флаги используются для описания тиков, полученных функциями copy_ticks_from() и  copy_ticks_range().

    ID

    Описание

    TICK_FLAG_BID

    цена Bid изменилась

    TICK_FLAG_ASK

    цена Ask изменилась

    TICK_FLAG_LAST

    цена Last изменилась

    TICK_FLAG_VOLUME

    объём изменился

    TICK_FLAG_BUY

    последняя цена Buy изменилась

    TICK_FLAG_SELL

    последняя цена Sell изменилась

        def __tick_flag_mask(self, flags: int) -> int:
            if flags == mt5.COPY_TICKS_ALL:
                return (
                    mt5.TICK_FLAG_BID
                    | mt5.TICK_FLAG_ASK
                    | mt5.TICK_FLAG_LAST
                    | mt5.TICK_FLAG_VOLUME
                    | mt5.TICK_FLAG_BUY
                    | mt5.TICK_FLAG_SELL
                )
    
            mask = 0
            if flags & mt5.COPY_TICKS_INFO:
                mask |= mt5.TICK_FLAG_BID | mt5.TICK_FLAG_ASK
            if flags & mt5.COPY_TICKS_TRADE:
                mask |= mt5.TICK_FLAG_LAST | mt5.TICK_FLAG_VOLUME
    
            return mask

    Пример использования:

    sim = Simulator(simulator_name="MySimulator", mt5_instance=mt5, deposit=1078.30, leverage="1:500")
    
    start = datetime(2025, 1, 1)
    end = datetime(2025, 1, 5)
    
    bars = 10
    symbol = "EURUSD"
    timeframe = mt5.TIMEFRAME_H1
    
    sim.Start(IS_TESTER=True) # start simulation in the strategy tester
    
    ticks = sim.copy_ticks_from(symbol=symbol, date_from=start.replace(month=12, hour=0, minute=0), count=bars)
    
    print("is_tester=true\n", ticks)
    
    sim.Start(IS_TESTER=False) # start the simulator in real-time trading
    
    ticks = sim.copy_ticks_from(symbol=symbol, date_from=start.replace(month=12, hour=0, minute=0), count=bars)
    
    print("is_tester=false\n", ticks)

    Вывод.

    is_tester=true
     [{'time': 1764547200, 'bid': 1.15936, 'ask': 1.1596899999999999, 'last': 0.0, 'volume': 0, 'time_msc': 1764547200174, 'flags': 134, 'volume_real': 0.0}
     {'time': 1764547206, 'bid': 1.15934, 'ask': 1.15962, 'last': 0.0, 'volume': 0, 'time_msc': 1764547206476, 'flags': 134, 'volume_real': 0.0}
     {'time': 1764547211, 'bid': 1.1593499999999999, 'ask': 1.15997, 'last': 0.0, 'volume': 0, 'time_msc': 1764547211273, 'flags': 134, 'volume_real': 0.0}
     {'time': 1764547215, 'bid': 1.15936, 'ask': 1.15979, 'last': 0.0, 'volume': 0, 'time_msc': 1764547215872, 'flags': 134, 'volume_real': 0.0}
     {'time': 1764547221, 'bid': 1.15936, 'ask': 1.15964, 'last': 0.0, 'volume': 0, 'time_msc': 1764547221475, 'flags': 4, 'volume_real': 0.0}
     {'time': 1764547231, 'bid': 1.1593499999999999, 'ask': 1.15997, 'last': 0.0, 'volume': 0, 'time_msc': 1764547231674, 'flags': 134, 'volume_real': 0.0}
     {'time': 1764547260, 'bid': 1.1593499999999999, 'ask': 1.15997, 'last': 0.0, 'volume': 0, 'time_msc': 1764547260073, 'flags': 130, 'volume_real': 0.0}
     {'time': 1764547265, 'bid': 1.15892, 'ask': 1.15998, 'last': 0.0, 'volume': 0, 'time_msc': 1764547265485, 'flags': 134, 'volume_real': 0.0}
     {'time': 1764547320, 'bid': 1.15892, 'ask': 1.15998, 'last': 0.0, 'volume': 0, 'time_msc': 1764547320074, 'flags': 130, 'volume_real': 0.0}
     {'time': 1764547345, 'bid': 1.15894, 'ask': 1.15998, 'last': 0.0, 'volume': 0, 'time_msc': 1764547345872, 'flags': 134, 'volume_real': 0.0}]
    is_tester=false
     [{'time': 1764547200, 'bid': 1.15936, 'ask': 1.1596899999999999, 'last': 0.0, 'volume': 0, 'time_msc': 1764547200174, 'flags': 134, 'volume_real': 0.0}
     {'time': 1764547206, 'bid': 1.15934, 'ask': 1.15962, 'last': 0.0, 'volume': 0, 'time_msc': 1764547206476, 'flags': 134, 'volume_real': 0.0}
     {'time': 1764547211, 'bid': 1.1593499999999999, 'ask': 1.15997, 'last': 0.0, 'volume': 0, 'time_msc': 1764547211273, 'flags': 134, 'volume_real': 0.0}
     {'time': 1764547215, 'bid': 1.15936, 'ask': 1.15979, 'last': 0.0, 'volume': 0, 'time_msc': 1764547215872, 'flags': 134, 'volume_real': 0.0}
     {'time': 1764547221, 'bid': 1.15936, 'ask': 1.15964, 'last': 0.0, 'volume': 0, 'time_msc': 1764547221475, 'flags': 4, 'volume_real': 0.0}
     {'time': 1764547231, 'bid': 1.1593499999999999, 'ask': 1.15997, 'last': 0.0, 'volume': 0, 'time_msc': 1764547231674, 'flags': 134, 'volume_real': 0.0}
     {'time': 1764547260, 'bid': 1.1593499999999999, 'ask': 1.15997, 'last': 0.0, 'volume': 0, 'time_msc': 1764547260073, 'flags': 130, 'volume_real': 0.0}
     {'time': 1764547265, 'bid': 1.15892, 'ask': 1.15998, 'last': 0.0, 'volume': 0, 'time_msc': 1764547265485, 'flags': 134, 'volume_real': 0.0}
     {'time': 1764547320, 'bid': 1.15892, 'ask': 1.15998, 'last': 0.0, 'volume': 0, 'time_msc': 1764547320074, 'flags': 130, 'volume_real': 0.0}
     {'time': 1764547345, 'bid': 1.15894, 'ask': 1.15998, 'last': 0.0, 'volume': 0, 'time_msc': 1764547345872, 'flags': 134, 'volume_real': 0.0}]

    copy_ticks_range

    Согласно документации, эта функция получает тики из терминала MetaTrader 5 за указанный диапазон дат.

    Сигнатура функции:

    copy_ticks_range(
       symbol,       // symbol name
       date_from,    // date the ticks are requested from
       date_to,      // date, up to which the ticks are requested
       flags         // combination of flags defining the type of requested ticks
       )

    Ниже приведена аналогичная реализация функции внутри класса Simulator.

         def copy_ticks_range(self, symbol: str, date_from: datetime, date_to: datetime, flags: int=mt5.COPY_TICKS_ALL) -> np.array:
            
            date_from = utils.ensure_utc(date_from)
            date_to = utils.ensure_utc(date_to)
            
            flag_mask = self.__tick_flag_mask(flags)
    
            if self.IS_TESTER:    
                
                path = os.path.join(config.TICKS_HISTORY_DIR, symbol)
                lf = pl.scan_parquet(path)
    
                try:
                    ticks = (
                        lf
                        .filter(
                                (pl.col("time") >= pl.lit(date_from)) &
                                (pl.col("time") <= pl.lit(date_to))
                            ) # get ticks between date_from and date_to
                        .filter((pl.col("flags") & flag_mask) != 0)
                        .sort(
                            ["time", "time_msc"],
                            descending=[False, False]
                        )
                        .select([
                            pl.col("time").dt.epoch("s").cast(pl.Int64).alias("time"),
    
                            pl.col("bid"),
                            pl.col("ask"),
                            pl.col("last"),
                            pl.col("volume"),
                            pl.col("time_msc"),
                            pl.col("flags"),
                            pl.col("volume_real"),
                        ]) 
                        .collect(engine="streaming") # the streaming engine, doesn't store data in memory
                    ).to_dicts()
    
                    ticks = np.array(ticks)
                
                except Exception as e:
                    self.__GetLogger().warning(f"Failed to copy ticks {e}")
                    return np.array(dict())
            else:
                
                ticks = self.mt5_instance.copy_ticks_range(symbol, date_from, date_to, flags)
                ticks = np.array(self.__mt5_data_to_dicts(ticks))
                
                if ticks is None:
                    self.__GetLogger().warning(f"Failed to copy ticks. MetaTrader 5 error = {self.mt5_instance.last_error()}")
                    return np.array(dict())
                
            return ticks
    

    Пример использования:

    sim = Simulator(simulator_name="MySimulator", mt5_instance=mt5, deposit=1078.30, leverage="1:500")
    
    sim.Start(IS_TESTER=True) # start simulation in the strategy tester
    
    ticks = sim.copy_ticks_range(symbol=symbol, 
                                 date_from=start.replace(month=12, hour=0, minute=0),
                                 date_to=end.replace(month=12, hour=0, minute=5))
    
    print("is_tester=true\n", ticks)
    
    sim.Start(IS_TESTER=False) # start the simulator in real-time trading
    
    ticks = sim.copy_ticks_range(symbol=symbol, 
                                 date_from=start.replace(month=12, hour=0, minute=0),
                                 date_to=end.replace(month=12, hour=0, minute=5))
    
    print("is_tester=false\n", ticks)

    Вывод.

    is_tester=true
     [{'time': 1764547200, 'bid': 1.15936, 'ask': 1.1596899999999999, 'last': 0.0, 'volume': 0, 'time_msc': 1764547200174, 'flags': 134, 'volume_real': 0.0}
     {'time': 1764547206, 'bid': 1.15934, 'ask': 1.15962, 'last': 0.0, 'volume': 0, 'time_msc': 1764547206476, 'flags': 134, 'volume_real': 0.0}
     {'time': 1764547211, 'bid': 1.1593499999999999, 'ask': 1.15997, 'last': 0.0, 'volume': 0, 'time_msc': 1764547211273, 'flags': 134, 'volume_real': 0.0}
     ...
     {'time': 1764550799, 'bid': 1.15965, 'ask': 1.16006, 'last': 0.0, 'volume': 0, 'time_msc': 1764550799475, 'flags': 134, 'volume_real': 0.0}
     {'time': 1764550799, 'bid': 1.15971, 'ask': 1.16011, 'last': 0.0, 'volume': 0, 'time_msc': 1764550799669, 'flags': 134, 'volume_real': 0.0}
     {'time': 1764550799, 'bid': 1.15965, 'ask': 1.16006, 'last': 0.0, 'volume': 0, 'time_msc': 1764550799877, 'flags': 134, 'volume_real': 0.0}]
    is_tester=false
     [{'time': 1764547200, 'bid': 1.15936, 'ask': 1.1596899999999999, 'last': 0.0, 'volume': 0, 'time_msc': 1764547200174, 'flags': 134, 'volume_real': 0.0}
     {'time': 1764547206, 'bid': 1.15934, 'ask': 1.15962, 'last': 0.0, 'volume': 0, 'time_msc': 1764547206476, 'flags': 134, 'volume_real': 0.0}
     {'time': 1764547211, 'bid': 1.1593499999999999, 'ask': 1.15997, 'last': 0.0, 'volume': 0, 'time_msc': 1764547211273, 'flags': 134, 'volume_real': 0.0}
     ...
     {'time': 1764893040, 'bid': 1.16424, 'ask': 1.16479, 'last': 0.0, 'volume': 0, 'time_msc': 1764893040071, 'flags': 130, 'volume_real': 0.0}
     {'time': 1764893061, 'bid': 1.16424, 'ask': 1.16479, 'last': 0.0, 'volume': 0, 'time_msc': 1764893061887, 'flags': 4, 'volume_real': 0.0}
     {'time': 1764893096, 'bid': 1.16424, 'ask': 1.16482, 'last': 0.0, 'volume': 0, 'time_msc': 1764893096077, 'flags': 4, 'volume_real': 0.0}]

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

    orders_total

    Согласно документации, эта функция получает количество активных ордеров из терминала MetaTrader 5

    orders_total()

    Она возвращает целочисленное значение.

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

    def orders_total(self) -> int:
            
        """Get the number of active orders.
            
        Returns (int): The number of active orders in either a simulator or MetaTrader 5
            """
            
        return len(self.orders_container) if self.IS_TESTER else self.mt5_instance.orders_total()

    orders_get

    Согласно документации,эта функция получает активные ордера с возможностью фильтрации по символу или тикету. Есть три варианта вызова.

    orders_get()
    

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

    orders_get(
       symbol="SYMBOL"      // symbol name
    )

    Вызов с указанием группы символов, по которым нужно получить активные ордера.

    orders_get(
       group="GROUP"        // filter for selecting orders for symbols
    )

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

    orders_get(
       ticket=TICKET        // ticket
    )

    Эта функция возвращает информацию в виде структуры именованного кортежа (namedtuple). В случае ошибки возвращает None. Информацию об ошибке можно получить с помощью last_error().

    Чтобы наш симулятор был максимально похож на терминал MetaTrader 5, мы должны возвращать похожий тип данных (namedtuple). 

    from collections import namedtuple

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

    def orders_get(self, symbol: Optional[str] = None, group: Optional[str] = None, ticket: Optional[int] = None) -> namedtuple:
        """G et active orders with the ability to filter by symbol or ticket. Есть три варианта вызова.
    
        Returns:
            
            list: Returns info in the form of a named tuple structure (namedtuple). В случае ошибки возвращает None. Информацию об ошибке можно получить с помощью last_error().
        """

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

        def __init__(self, simulator_name: str, mt5_instance: mt5, deposit: float, leverage: str="1:100"):
            
            # ----------------- TradeOrder --------------------------
            
            self.TradeOrder = namedtuple(
                "TradeOrder",
                [
                    "ticket",
                    "time_setup",
                    "time_setup_msc",
                    "time_done",
                    "time_done_msc",
                    "time_expiration",
                    "type",
                    "type_time",
                    "type_filling",
                    "state",
                    "magic",
                    "position_id",
                    "position_by_id",
                    "reason",
                    "volume_initial",
                    "volume_current",
                    "price_open",
                    "sl",
                    "tp",
                    "price_current",
                    "price_stoplimit",
                    "symbol",
                    "comment",
                    "external_id",
                ]
            )

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

        def orders_get(self, symbol: Optional[str] = None, group: Optional[str] = None, ticket: Optional[int] = None) -> namedtuple:
            
            self.__orders_container__.extend([order1, order2])
            
            if self.IS_TESTER:
                
                orders = self.__orders_container__
    
                # no filters → return all orders
                if symbol is None and group is None and ticket is None:
                    return tuple(orders)
    
                # symbol filter (highest priority)
                if symbol is not None:
                    return tuple(o for o in orders if o.symbol == symbol)
    
                # group filter
                if group is not None:
                    return tuple(o for o in orders if fnmatch.fnmatch(o.symbol, group))
    
                # ticket filter
                if ticket is not None:
                    return tuple(o for o in orders if o.ticket == ticket)
    
                return tuple()
            
            try:
                if symbol is not None:
                    return self.mt5_instance.orders_get(symbol=symbol)
    
                if group is not None:
                    return self.mt5_instance.orders_get(group=group)
    
                if ticket is not None:
                    return self.mt5_instance.orders_get(ticket=ticket)
    
                return self.mt5_instance.orders_get()
    
            except Exception:
                return None

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

    С двумя отложенными ордерами в моём терминале MetaTrader 5:

    И две симулированные сделки:

            order1 = self.TradeOrder(
                ticket=123456,
                time_setup=int(datetime.now().timestamp()),
                time_setup_msc=int(datetime.now().timestamp() * 1000),
                time_done=0,
                time_done_msc=0,
                time_expiration=0,
                type=mt5.ORDER_TYPE_BUY_LIMIT,
                type_time=0,
                type_filling=mt5.ORDER_FILLING_RETURN,
                state=mt5.ORDER_STATE_PLACED,
                magic=0,
                position_id=0,
                position_by_id=0,
                reason=0,
                volume_initial=0.01,
                volume_current=0.01,
                price_open=1.1750,
                sl=1.1700,
                tp=1.1800,
                price_current=1.1750,
                price_stoplimit=0.0,
                symbol="GBPUSD",
                comment="",
                external_id="",
            )
    
            order2 = self.TradeOrder(
                ticket=123457,
                time_setup=int(datetime.now().timestamp()),
                time_setup_msc=int(datetime.now().timestamp() * 1000),
                time_done=0,
                time_done_msc=0,
                time_expiration=0,
                type=mt5.ORDER_TYPE_SELL_LIMIT,
                type_time=0,
                type_filling=mt5.ORDER_FILLING_RETURN,
                state=mt5.ORDER_STATE_PLACED,
                magic=0,
                position_id=0,
                position_by_id=0,
                reason=0,
                volume_initial=0.01,
                volume_current=0.01,
                price_open=1.1800,
                sl=1.1850,
                tp=1.1700,
                price_current=1.1800,
                price_stoplimit=0.0,
                symbol="EURUSD",
                comment="",
                external_id="",
            )
            
            self.__orders_container__.extend([order1, order2])        

    Затем мы проверяем наличие ордеров как в MetaTrader 5, так и в симуляторе.

    sim = Simulator(simulator_name="MySimulator", mt5_instance=mt5, deposit=1078.30, leverage="1:500")
    
    sim.Start(IS_TESTER=True) # start simulation in the strategy tester
    print("Orders in the simulator:\n", sim.orders_get())
    
    
    sim.Start(IS_TESTER=False) # start the simulator in real-time trading
    print("Orders in MetaTrader 5:\n", sim.orders_get())

    Вывод:

    Ордера в симуляторе:
     (TradeOrder(ticket=123456, time_setup=1766749779, time_setup_msc=1766749779726, time_done=0, time_done_msc=0, time_expiration=0, type=2, type_time=0, type_filling=2, state=1, magic=0, position_id=0, position_by_id=0, reason=0, volume_initial=0.01, volume_current=0.01, price_open=1.175, sl=1.17, tp=1.18, price_current=1.175, price_stoplimit=0.0, symbol='GBPUSD', comment='', external_id=''),
     TradeOrder(ticket=123457, time_setup=1766749779, time_setup_msc=1766749779726, time_done=0, time_done_msc=0, time_expiration=0, type=3, type_time=0, type_filling=2, state=1, magic=0, position_id=0, position_by_id=0, reason=0, volume_initial=0.01, volume_current=0.01, price_open=1.18, sl=1.185, tp=1.17, price_current=1.18, price_stoplimit=0.0, symbol='EURUSD', comment='', external_id=''))
    Orders in MetaTrader 5:
     (TradeOrder(ticket=1381968725, time_setup=1766748043, time_setup_msc=1766748043247, time_done=0, time_done_msc=0, time_expiration=0, type=2, type_time=0, type_filling=2, state=1, magic=0, position_id=0, position_by_id=0, reason=0, volume_initial=0.01, volume_current=0.01, price_open=1.17414, sl=0.0, tp=0.0, price_current=1.17769, price_stoplimit=0.0, symbol='EURUSD', comment='', external_id=''), 
    TradeOrder(ticket=1381968767, time_setup=1766748049, time_setup_msc=1766748049051, time_done=0, time_done_msc=0, time_expiration=0, type=3, type_time=0, type_filling=2, state=1, magic=0, position_id=0, position_by_id=0, reason=0, volume_initial=0.01, volume_current=0.01, price_open=1.17949, sl=0.0, tp=0.0, price_current=1.17769, price_stoplimit=0.0, symbol='EURUSD', comment='', external_id=''))
    

    positions_total

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

    positions_total()

    Ниже приведён аналогичный метод в симуляторе.

        def positions_total(self) -> int:
            """Get the number of open positions in MetaTrader 5 client.
    
            Returns:
                int: number of positions
            """
            
            if self.IS_TESTER:
                return len(self.__positions_container__)        
            try:
                total = self.mt5_instance.positions_total()
            except Exception as e:
                self.__GetLogger().error(f"MetaTrader5 error = {e}")
                return -1
            
            return total

    positions_get

    Этот метод выглядит и работает аналогично методу orders_get, который мы только что рассмотрели выше.

    Из документации:

    Функция получает открытые позиции с возможностью фильтрации по символу или тикету. У неё есть три варианта вызова.

    Вызов без параметров возвращает открытые позиции по всем символам.

    positions_get()

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

    positions_get(
       symbol="SYMBOL"      // symbol name
    )

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

    positions_get(
       group="GROUP"        // filter for selecting positions by symbols
    )

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

    positions_get(
       ticket=TICKET        // ticket
    )

    Подобно методу orders_get, этот метод возвращает данные в виде структуры именованного кортежа namedtuple. В случае ошибки возвращает None. Информацию об ошибке можно получить с помощью last_error().

    С учётом этого нам нужна аналогичная структура для хранения информации о позициях в нашем симуляторе — похожая на ту, которую возвращает модуль MetaTrader 5-Python.

        def positions_get(self, symbol: Optional[str] = None, group: Optional[str] = None, ticket: Optional[int] = None) -> namedtuple:
            
            if self.IS_TESTER:
                
                positions = self.__positions_container__
    
                # no filters → return all positions
                if symbol is None and group is None and ticket is None:
                    return tuple(positions)
    
                # symbol filter (highest priority)
                if symbol is not None:
                    return tuple(o for o in positions if o.symbol == symbol)
    
                # group filter
                if group is not None:
                    return tuple(o for o in positions if fnmatch.fnmatch(o.symbol, group))
    
                # ticket filter
                if ticket is not None:
                    return tuple(o for o in positions if o.ticket == ticket)
    
                return tuple()
            
            try:
                if symbol is not None:
                    return self.mt5_instance.positions_get(symbol=symbol)
    
                if group is not None:
                    return self.mt5_instance.positions_get(group=group)
    
                if ticket is not None:
                    return self.mt5_instance.positions_get(ticket=ticket)
    
                return self.mt5_instance.positions_get()
    
            except Exception:
                return None

    Пример использования:

    sim = Simulator(simulator_name="MySimulator", mt5_instance=mt5, deposit=1078.30, leverage="1:500")
    
    sim.Start(IS_TESTER=True) # start simulation in the strategy tester
    print("positions total in the Simulator: ",sim.positions_total())
    print("positions in the Simulator:\n",sim.positions_get())
    
    sim.Start(IS_TESTER=False) # start the simulator in real-time trading
    print("positions total in MetaTrader5: ",sim.positions_total())
    print("positions in MetaTraer5:\n",sim.positions_get())

    Вывод.

    (venv) C:\Users\Omega Joctan\OneDrive\Documents\PyMetaTester>python test.py
    positions total in the Simulator:  0
    positions in the Simulator:
     ()
    positions total in MetaTrader5:  2
    positions in MetaTraer5:
     (TradePosition(ticket=1381981938, time=1766748992, time_msc=1766748992425, time_update=1766748992, time_update_msc=1766748992425, type=0, magic=0, identifier=1381981938, reason=0, volume=0.01, price_open=1.17688, sl=0.0, tp=0.0, price_current=1.17755, swap=0.0, profit=0.67, symbol='EURUSD', comment='', external_id=''), 
    TradePosition(ticket=1381981988, time=1766748994, time_msc=1766748994018, time_update=1766748994, time_update_msc=1766748994018, type=1, magic=0, identifier=1381981988, reason=0, volume=0.01, price_open=1.17688, sl=0.0, tp=0.0, price_current=1.17755, swap=0.0, profit=-0.67, symbol='EURUSD', comment='', external_id=''))
    

    history_orders_total

    Согласно документации, этот метод получает количество ордеров в торговой истории за определённый временной интервал.

    history_orders_total(
       date_from,    // date the orders are requested from
       date_to       // date, up to which the orders are requested
       )

    Параметры:

    • date_from: дата, начиная с которой запрашиваются ордера. Задаётся объектом datetime или количеством секунд, прошедших с 1970.01.01. 
    • date_to: дата, до которой запрашиваются ордера. Задаётся объектом datetime или количеством секунд, прошедших с 1970.01.01.

    Аналогичная функция в симуляторе может быть реализована следующим образом:

        def history_orders_total(self, date_from: datetime, date_to: datetime) -> int:
            
            # date range is a requirement
            
            if date_from is None or date_to is None:
                self.__GetLogger().error("date_from and date_to must be specified")
                return None
                
            date_from = utils.ensure_utc(date_from)
            date_to = utils.ensure_utc(date_to)
            
            if self.IS_TESTER:
            
                date_from_ts = int(date_from.timestamp())
                date_to_ts   = int(date_to.timestamp())
                
                return sum(
                            1
                            for o in self.__orders_history_container__
                            if date_from_ts <= o.time_setup <= date_to_ts
                        )
    
            try:
                total = self.mt5_instance.history_orders_total(date_from, date_to)
            except Exception as e:
                self.__GetLogger().error(f"MetaTrader5 error = {e}")
                return -1
            
            return total

    Пример использования:

    sim.Start(IS_TESTER=True) # start simulation in the strategy tester
    
    date_to = datetime.now()
    date_from = date_to - timedelta(days=1)
    
    print(sim.history_orders_total(date_from=date_from,date_to=date_to))
    
    sim.Start(IS_TESTER=False) # start the simulator in real-time trading
    
    print(sim.history_orders_total(date_from=date_from,date_to=date_to))

    Вывод.

    (venv) C:\Users\Omega Joctan\OneDrive\Documents\PyMetaTester>python test.py
    orders in the last 24 hours in the Simulator: 0
    orders in the last 24 hours in MetaTrader5: 3

    history_orders_get

    Согласно документации, этот метод получает ордера из торговой истории с возможностью фильтрации по тикету или позиции. 

    Он возвращает все ордера, попадающие в указанный интервал.

    У неё есть три варианта вызова.

    history_orders_get(
       date_from,                // date the orders are requested from
       date_to,                  // date, up to which the orders are requested
       group="GROUP"        // filter for selecting orders by symbols
       )

    Вызов с указанием тикета ордера. Возвращает ордер с указанным тикетом.

    history_orders_get(
       ticket=TICKET        // order ticket
    )

    Вызов с указанием тикета позиции. Возвращает все ордера с тикетом позиции, указанным в  свойстве ORDER_POSITION_ID .

    history_orders_get(
       position=POSITION    // position ticket
    )

    Как и внутри функции history_orders_total, мы читаем всю информацию из массива с именем __orders_history_container__ с дополнительными фильтрами по тикету (ticket, тикет ордера), позиции (position, тикет сохранённой позиции) и группе (group, фильтр для выбора нужной группы символов).

        def history_orders_get(self, 
                               date_from: datetime,
                               date_to: datetime,
                               group: Optional[str] = None,
                               ticket: Optional[int] = None,
                               position: Optional[int] = None
                               ) -> namedtuple:
            
            if self.IS_TESTER:
    
                orders = self.__orders_history_container__
    
                # ticket filter (highest priority)
                if ticket is not None:
                    return tuple(o for o in orders if o.ticket == ticket)
    
                # position filter
                if position is not None:
                    return tuple(o for o in orders if o.position_id == position)
    
                # date range is a requirement  
                if date_from is None or date_to is None:
                    self.__GetLogger().error("date_from and date_to must be specified")
                    return None
    
                date_from_ts = int(utils.ensure_utc(date_from).timestamp())
                date_to_ts   = int(utils.ensure_utc(date_to).timestamp())
    
                filtered = (
                    o for o in orders
                    if date_from_ts <= o.time_setup <= date_to_ts
                ) # obtain orders that fall within this time range
    
                # optional group filter
                if group is not None:
                    filtered = (
                        o for o in filtered
                        if fnmatch.fnmatch(o.symbol, group)
                    )
    
                return tuple(filtered)
        
            try: # we are not on the strategy tester simulation
                
                if ticket is not None:
                    return self.mt5_instance.history_orders_get(date_from, date_to, ticket=ticket)
    
                if position is not None:
                    return self.mt5_instance.history_orders_get(date_from, date_to, position=position)
    
                if date_from is None or date_to is None:
                    raise ValueError("date_from and date_to are required")
    
                date_from = utils.ensure_utc(date_from)
                date_to   = utils.ensure_utc(date_to)
    
                if group is not None:
                    return self.mt5_instance.history_orders_get(
                        date_from, date_to, group=group
                    )
    
                return self.mt5_instance.history_orders_get(date_from, date_to)
    
            except Exception as e:
                self.__GetLogger().error(f"MetaTrader5 error = {e}")
                return None

    history_deals_total

    Согласно документации, эта функция получает количество сделок в торговой истории за указанный интервал.

    history_deals_total(
       date_from,    // date the deals are requested from
       date_to       // date, up to which the deals are requested
       )

    Параметры:

    • date_from: Дата, начиная с которой запрашиваются сделки. Задаётся объектом datetime или количеством секунд, прошедших с 1970.01.01. 
    • date_to: Дата, до которой запрашиваются сделки. Задаётся объектом datetime или количеством секунд, прошедших с 1970.01.01.

        def history_deals_total(self, date_from: datetime, date_to: datetime) -> int:
            """
            Get the number of deals in history within the specified date range.
    
            Args:
                date_from (datetime): Date the orders are requested from. Задаётся объектом datetime или количеством секунд, прошедших с 1970.01.01. 
                
                date_to (datetime, required): Date, up to which the orders are requested. Задаётся объектом datetime или количеством секунд, прошедших с 1970.01.01.
            
            Returns:
                An integer value.
            """
    
            if date_from is None or date_to is None:
                self.__GetLogger().error("date_from and date_to must be specified")
                return -1
    
            date_from = utils.ensure_utc(date_from)
            date_to   = utils.ensure_utc(date_to)
    
            if self.IS_TESTER:
    
                date_from_ts = int(date_from.timestamp())
                date_to_ts   = int(date_to.timestamp())
    
                return sum(
                    1
                    for d in self.__deals_history_container__
                    if date_from_ts <= d.time <= date_to_ts
                )
    
            try:
                return self.mt5_instance.history_deals_total(date_from, date_to)
    
            except Exception as e:
                self.__GetLogger().error(f"MetaTrader5 error = {e}")
                return -1

    Пример использования:

    sim = Simulator(simulator_name="MySimulator", mt5_instance=mt5, deposit=1078.30, leverage="1:500")
    
    date_to = datetime.now()
    date_from = date_to - timedelta(days=1)
    
    print("Total deals in the last 24 hours in MetaTrader5:", sim.history_deals_total(date_from=date_from,date_to=date_to))
    
    sim.Start(IS_TESTER=False) # start the simulator in real-time trading
    
    print("Total deals in the last 24 hours in MetaTrader5:", sim.history_deals_total(date_from=date_from,date_to=date_to))

    Вывод.

    Total deals in the last 24 hours in MetaTrader5: 0
    Total deals in the last 24 hours in MetaTrader5: 3

    history_deals_get

    Согласно документации, этот метод получает сделки из торговой истории за указанный временной интервал с возможностью фильтрации по тикету или позиции.

    Функция имеет три варианта вызова.

    history_deals_get(
       date_from,                // date the deals are requested from
       date_to,                  // date, up to which the deals are requested
       group="GROUP"             // filter for selecting deals for symbols
       )

    Вызов с указанием тикета ордера. Возвращает все сделки, у которых указанный тикет ордера находится в свойстве DEAL_ORDER .

    history_deals_get(
       ticket=TICKET        // order ticket
    )

    Вызов с указанием тикета позиции. Возвращает все сделки, у которых указанный тикет позиции находится в свойстве DEAL_POSITION_ID .

    history_deals_get(
       position=POSITION    // position ticket
    )

    В классе Simulator мы создадим метод с таким же именем. Когда пользователь выбирает режим тестера стратегий (IS_TESTER=True), история сделок извлекается из массива внутри симулятора; в противном случае такая информация извлекается напрямую из клиента MetaTrader 5.

        def history_deals_get(self,
                              date_from: datetime,
                              date_to: datetime,
                              group: Optional[str] = None,
                              ticket: Optional[int] = None,
                              position: Optional[int] = None
                            ) -> namedtuple:
                    
            if self.IS_TESTER:
    
                deals = self.__deals_history_container__
    
                # ticket filter (highest priority)
                if ticket is not None:
                    return tuple(d for d in deals if d.ticket == ticket)
    
                # position filter
                if position is not None:
                    return tuple(d for d in deals if d.position_id == position)
    
                # date range is a requirement  
                if date_from is None or date_to is None:
                    self.__GetLogger().error("date_from and date_to must be specified")
                    return None
    
                date_from_ts = int(utils.ensure_utc(date_from).timestamp())
                date_to_ts   = int(utils.ensure_utc(date_to).timestamp())
    
                filtered = (
                    d for d in deals
                    if date_from_ts <= d.time <= date_to_ts
                ) # obtain orders that fall within this time range
    
                # optional group filter
                if group is not None:
                    filtered = (
                        d for d in filtered
                        if fnmatch.fnmatch(d.symbol, group)
                    )
    
                return tuple(filtered)
        
            try: # we are not on the strategy tester simulation
                
                if ticket is not None:
                    return self.mt5_instance.history_deals_get(date_from, date_to, ticket=ticket)
    
                if position is not None:
                    return self.mt5_instance.history_deals_get(date_from, date_to, position=position)
    
                if date_from is None or date_to is None:
                    raise ValueError("date_from and date_to are required")
    
                date_from = utils.ensure_utc(date_from)
                date_to   = utils.ensure_utc(date_to)
    
                if group is not None:
                    return self.mt5_instance.history_deals_get(
                        date_from, date_to, group=group
                    )
    
                return self.mt5_instance.history_deals_get(date_from, date_to)
    
            except Exception as e:
                self.__GetLogger().error(f"MetaTrader5 error = {e}")
                return None

    Пример использования:

    sim = Simulator(simulator_name="MySimulator", mt5_instance=mt5, deposit=1078.30, leverage="1:500")
    
    sim.Start(IS_TESTER=True) # start simulation in the strategy tester
    
    date_to = datetime.now()
    date_from = date_to - timedelta(days=1)
    
    print("deals in the last 24 hours in the Simulator:\n", sim.history_deals_get(date_from=date_from,date_to=date_to))
    
    sim.Start(IS_TESTER=False) # start the simulator in real-time trading
    
    print("Deals in the last 24 hours in MetaTrader5:\n", sim.history_deals_get(date_from=date_from,date_to=date_to))

    Вывод.

    (venv) C:\Users\Omega Joctan\OneDrive\Documents\PyMetaTester>python test.py
    deals in the last 24 hours in the Simulator:
     ()
    Deals in the last 24 hours in MetaTrader5:
     (TradeDeal(ticket=1134768493, order=1381981938, time=1766748992, time_msc=1766748992425, type=0, entry=0, magic=0, position_id=1381981938, reason=0, volume=0.01, price=1.17688, commission=-0.04, swap=0.0, profit=0.0, fee=0.0, symbol='EURUSD', comment='', external_id=''), 
    TradeDeal(ticket=1134768532, order=1381981988, time=1766748994, time_msc=1766748994018, type=1, entry=0, magic=0, position_id=1381981988, reason=0, volume=0.01, price=1.17688, commission=-0.04, swap=0.0, profit=0.0, fee=0.0, symbol='EURUSD', comment='', external_id=''), 
    TradeDeal(ticket=1135016562, order=1381968767, time=1766763381, time_msc=1766763381530, type=1, entry=0, magic=0, position_id=1381968767, reason=0, volume=0.01, price=1.17953, commission=-0.04, swap=0.0, profit=0.0, fee=0.0, symbol='EURUSD', comment='', external_id=''))
    

    account_info

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

    Если вы запросите информацию о счёте из MetaTrader 5 с помощью метода account_info(), вы увидите кортеж, который выглядит так:

    AccountInfo(login=52557820, trade_mode=0, leverage=500, limit_orders=200, margin_so_mode=0, trade_allowed=True, trade_expert=True, margin_mode=2, currency_digits=2, fifo_close=False, balance=941.54, credit=0.0, profit=2.37, equity=943.91, margin=2.36, margin_free=941.55, margin_level=39996.18644067797, margin_so_call=100.0, margin_so_so=0.0, margin_initial=0.0, margin_maintenance=0.0, assets=0.0, liabilities=0.0, commission_blocked=0.0, name='OMEGA MSIGWA', server='ICMarketsSC-Demo', currency='USD', company='Raw Trading Ltd')

    В документации сказано, что функция возвращает информацию в виде структуры именованного кортежа (namedtuple). В случае ошибки возвращает None. Информацию об ошибке можно получить с помощью last_error().

    Мы определяем аналогичную структуру внутри класса Simulator.

            self.AccountInfo = namedtuple(
                "AccountInfo",
                [
                    "login",
                    "trade_mode",
                    "leverage",
                    "limit_orders",
                    "margin_so_mode",
                    "trade_allowed",
                    "trade_expert",
                    "margin_mode",
                    "currency_digits",
                    "fifo_close",
                    "balance",
                    "credit",
                    "profit",
                    "equity",
                    "margin",
                    "margin_free",
                    "margin_level",
                    "margin_so_call",
                    "margin_so_so",
                    "margin_initial",
                    "margin_maintenance",
                    "assets",
                    "liabilities",
                    "commission_blocked",
                    "name",
                    "server",
                    "currency",
                    "company",
                ]
            )
    

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

            mt5_acc_info = mt5_instance.account_info()
    
            if mt5_acc_info is None:
                raise RuntimeError("Failed to obtain MT5 account info")
    
            self.__account_state_update(
                account_info=self.AccountInfo(
                    # ---- identity / broker-controlled ----
                    login=11223344,
                    trade_mode=mt5_acc_info.trade_mode,
                    leverage=int(leverage.split(":")[1]),
                    limit_orders=mt5_acc_info.limit_orders,
                    margin_so_mode=mt5_acc_info.margin_so_mode,
                    trade_allowed=mt5_acc_info.trade_allowed,
                    trade_expert=mt5_acc_info.trade_expert,
                    margin_mode=mt5_acc_info.margin_mode,
                    currency_digits=mt5_acc_info.currency_digits,
                    fifo_close=mt5_acc_info.fifo_close,
    
                    # ---- simulator-controlled financials ----
                    balance=deposit,                # simulator starting balance
                    credit=mt5_acc_info.credit,
                    profit=0.0,
                    equity=deposit,
                    margin=0.0,
                    margin_free=deposit,
                    margin_level=0.0,
    
                    # ---- risk thresholds (copied from broker) ----
                    margin_so_call=mt5_acc_info.margin_so_call,
                    margin_so_so=mt5_acc_info.margin_so_so,
                    margin_initial=mt5_acc_info.margin_initial,
                    margin_maintenance=mt5_acc_info.margin_maintenance,
    
                    # ---- rarely used but keep parity ----
                    assets=mt5_acc_info.assets,
                    liabilities=mt5_acc_info.liabilities,
                    commission_blocked=mt5_acc_info.commission_blocked,
    
                    # ---- descriptive ----
                    name="John Doe",
                    server="MetaTrader5-Simulator",
                    currency=mt5_acc_info.currency,
                    company=mt5_acc_info.company,
                )
            )

    Мы заполняем все данные, кроме финансовых показателей, которые можем рассчитать сами: баланс счёта, equity, margin, free margin и margin level.

    Внутри функции account_info мы проверяем, выбрал ли пользователь режим тестера стратегий (IS_TESTER=True): в таком случае возвращаем информацию о счёте симулятора; в противном случае возвращаем информацию о счёте из MetaTrader 5.

        def account_info(self) -> namedtuple:
            
            """Gets info on the current trading account."""
            
            if self.IS_TESTER:
                return self.AccountInfo
            
            mt5_ac_info = self.mt5_instance.account_info()
            if  mt5_ac_info is None:
                self.__GetLogger().warning(f"Failed to obtain MT5 account info, MT5 Error = {self.mt5_instance.last_error()}")
                return
                
            return mt5_ac_info

    Пример использования:

    sim = Simulator(simulator_name="MySimulator", mt5_instance=mt5, deposit=1078.30, leverage="1:200")
    
    sim.Start(IS_TESTER=True) # start simulation in the strategy tester
    print("simulator's account info: ", sim.account_info())
    
    sim.Start(IS_TESTER=False) # start the simulator in real-time trading
    print("MetaTrader5's account info: ", sim.account_info())

    Вывод.

    (venv) C:\Users\Omega Joctan\OneDrive\Documents\PyMetaTester>python test.py
    simulator's account info:  AccountInfo(login=11223344, trade_mode=0, leverage=200, limit_orders=200, margin_so_mode=0, trade_allowed=True, trade_expert=True, margin_mode=2, currency_digits=2, fifo_close=False, balance=1078.3, credit=0.0, profit=0.0, equity=1078.3, margin=0.0, margin_free=1078.3, margin_level=0.0, margin_so_call=100.0, margin_so_so=0.0, margin_initial=0.0, margin_maintenance=0.0, assets=0.0, liabilities=0.0, commission_blocked=0.0, name='John Doe', server='MetaTrader5-Simulator', currency='USD', company='Raw Trading Ltd')
    MetaTrader5's account info:  AccountInfo(login=52557820, trade_mode=0, leverage=500, limit_orders=200, margin_so_mode=0, trade_allowed=True, trade_expert=True, margin_mode=2, currency_digits=2, fifo_close=False, balance=941.54, credit=0.0, profit=2.37, equity=943.91, margin=2.36, margin_free=941.55, margin_level=39996.18644067797, margin_so_call=100.0, margin_so_so=0.0, margin_initial=0.0, margin_maintenance=0.0, assets=0.0, liabilities=0.0, commission_blocked=0.0, name='OMEGA MSIGWA', server='ICMarketsSC-Demo', currency='USD', company='Raw Trading Ltd')
    

    order_calc_profit

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

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

    Эта функция возвращает прибыль в валюте счёта для указанной торговой операции.

    order_calc_profit(
       action,          // order type (ORDER_TYPE_BUY or ORDER_TYPE_SELL)
       symbol,          // symbol name
       volume,          // volume
       price_open,      // open price
       price_close      // close price
       );

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

    Подробное описание можно найти здесь: https://www.mql5.com/ru/book/automation/experts/experts_ordercalcprofit

    Ниже приведена таблица с формулами для оценки прибыли ордера в MetaTrader 5.

    Идентификатор

    Формула

    SYMBOL_CALC_MODE_FOREX

    (ClosePrice - OpenPrice) * ContractSize * Lots

    SYMBOL_CALC_MODE_FOREX_NO_LEVERAGE

    (ClosePrice - OpenPrice) * ContractSize * Lots

    SYMBOL_CALC_MODE_CFD

    (ClosePrice - OpenPrice) * ContractSize * Lots

    SYMBOL_CALC_MODE_CFDINDEX

    (ClosePrice - OpenPrice) * ContractSize * Lots

    SYMBOL_CALC_MODE_CFDLEVERAGE

    (ClosePrice - OpenPrice) * ContractSize * Lots

    SYMBOL_CALC_MODE_EXCH_STOCKS

    (ClosePrice - OpenPrice) * ContractSize * Lots

    SYMBOL_CALC_MODE_EXCH_STOCKS_MOEX

    (ClosePrice - OpenPrice) * ContractSize * Lots

    SYMBOL_CALC_MODE_FUTURES

    (ClosePrice - OpenPrice) * Lots * TickPrice / TickSize

    SYMBOL_CALC_MODE_EXCH_FUTURES

    (ClosePrice - OpenPrice) * Lots * TickPrice / TickSize

    SYMBOL_CALC_MODE_EXCH_FUTURES_FORTS

    (ClosePrice - OpenPrice) * Lots * TickPrice / TickSize

    SYMBOL_CALC_MODE_EXCH_BONDS

    Lots * ContractSize * (ClosePrice * FaceValue + AccruedInterest)

    SYMBOL_CALC_MODE_EXCH_BONDS_MOEX

    Lots * ContractSize * (ClosePrice * FaceValue + AccruedInterest)

    SYMBOL_CALC_MODE_SERV_COLLATERAL

    Lots * ContractSize * MarketPrice * LiqudityRate


    Мы вводим те же формулы в одноимённую функцию внутри нашего симулятора.

        def order_calc_profit(self, 
                            action: int,
                            symbol: str,
                            volume: float,
                            price_open: float,
                            price_close: float) -> float:
            """
            Return profit in the account currency for a specified trading operation.
            
            Args:
                action (int): The type of position taken, either 0 (buy) or 1 (sell).
                symbol (str): Financial instrument name. 
                volume (float):   Trading operation volume.
                price_open (float): Open Price.
                price_close (float): Close Price.
            """
            
    
            sym = self.symbol_info(symbol)
            
            if self.IS_TESTER:
                
                contract_size = sym.trade_contract_size
    
                # --- Determine direction ---
                if action == mt5.ORDER_TYPE_BUY:
                    direction = 1
                elif action == mt5.ORDER_TYPE_SELL:
                    direction = -1
                else:
                    self.__GetLogger().critical("order_calc_profit failed: invalid order type")
                    return 0.0
    
                # --- Core profit calculation ---
    
                calc_mode = sym.trade_calc_mode
                price_delta = (price_close - price_open) * direction
    
                try:
                    # ------------------ FOREX / CFD / STOCKS -----------------------
                    if calc_mode in (
                        mt5.SYMBOL_CALC_MODE_FOREX,
                        mt5.SYMBOL_CALC_MODE_FOREX_NO_LEVERAGE,
                        mt5.SYMBOL_CALC_MODE_CFD,
                        mt5.SYMBOL_CALC_MODE_CFDINDEX,
                        mt5.SYMBOL_CALC_MODE_CFDLEVERAGE,
                        mt5.SYMBOL_CALC_MODE_EXCH_STOCKS,
                        mt5.SYMBOL_CALC_MODE_EXCH_STOCKS_MOEX,
                    ):
                        profit = price_delta * contract_size * volume
    
                    # ---------------- FUTURES --------------------
                    elif calc_mode in (
                        mt5.SYMBOL_CALC_MODE_FUTURES,
                        mt5.SYMBOL_CALC_MODE_EXCH_FUTURES,
                        # mt5.SYMBOL_CALC_MODE_EXCH_FUTURES_FORTS,
                    ):
                        tick_value = sym.trade_tick_value
                        tick_size = sym.trade_tick_size
    
                        if tick_size <= 0:
                            self.__GetLogger().critical("Invalid tick size")
                            return 0.0
    
                        profit = price_delta * volume * (tick_value / tick_size)
    
                    # ---------- BONDS -------------------
                    
                    elif calc_mode in (
                        mt5.SYMBOL_CALC_MODE_EXCH_BONDS,
                        mt5.SYMBOL_CALC_MODE_EXCH_BONDS_MOEX,
                    ):
                        face_value = sym.trade_face_value
                        accrued_interest = sym.trade_accrued_interest
    
                        profit = (
                            volume
                            * contract_size
                            * (price_close * face_value + accrued_interest)
                            - volume
                            * contract_size
                            * (price_open * face_value)
                        )
    
                    # ------ COLLATERAL -------
                    elif calc_mode == mt5.SYMBOL_CALC_MODE_SERV_COLLATERAL:
                        liquidity_rate = sym.trade_liquidity_rate
                        market_price = (
                            self.tick.ask if action == mt5.ORDER_TYPE_BUY else self.tick.bid
                        )
    
                        profit = (
                            volume
                            * contract_size
                            * market_price
                            * liquidity_rate
                        )
    
                    else:
                        self.__GetLogger().critical(
                            f"Unsupported trade calc mode: {calc_mode}"
                        )
                        return 0.0
    
                    return round(profit, 2)
                    
                except Exception as e:
                    self.__GetLogger().critical(f"Failed: {e}")
                    return 0.0
                
            # if we are not on the strategy tester
                
            try:
                profit = self.mt5_instance.order_calc_profit(
                    action,
                    symbol,
                    volume,
                    price_open,
                    price_close
                )
            
            except Exception as e:
                self.__GetLogger().critical(f"Failed to calculate profit of a position, MT5 error = {self.mt5_instance.last_error()}")
                return np.nan
            
            return profit
        
    

    Пример использования:

    sim = Simulator(simulator_name="MySimulator", mt5_instance=mt5, deposit=1078.30, leverage="1:500")
    
    sim.Start(IS_TESTER=True) # start simulation in the strategy tester
    
    profit = sim.order_calc_profit(action=mt5.POSITION_TYPE_SELL, symbol=symbol, volume=0.01, price_open=entry, price_close=tp)
    print("Simulator profit caclulate: ", profit)
    
    sim.Start(IS_TESTER=False) # start the simulator in real-time trading
    
    profit = sim.order_calc_profit(action=mt5.POSITION_TYPE_SELL, symbol=symbol, volume=0.01, price_open=entry, price_close=tp)
    print("MT5 profit caclulate: ", round(profit, 2))

    Вывод.

    Simulator profit caclulate:  1.68
    MT5 profit caclulate:  1.68

    order_calc_margin

    Это ещё одна полезная функция в API MetaTrader 5, несмотря на то, что её работа менее известна.

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

    Следующая таблица представляет формулы, используемые для реализации функции order_calc_margin.

    Идентификатор

    Формула

    SYMBOL_CALC_MODE_FOREX

    Forex

    Lots * ContractSize * MarginRate / Leverage

    SYMBOL_CALC_MODE_FOREX_NO_LEVERAGE

    Forex without leverage

    Lots * ContractSize * MarginRate

    SYMBOL_CALC_MODE_CFD

    CFD

    Lots * ContractSize * MarketPrice * MarginRate

    SYMBOL_CALC_MODE_CFDLEVERAGE

    CFD with leverage

    Lots * ContractSize * MarketPrice * MarginRate / Leverage

    SYMBOL_CALC_MODE_CFDINDEX

    CFDs on indices

    Lots * ContractSize * MarketPrice * TickPrice / TickSize * MarginRate

    SYMBOL_CALC_MODE_EXCH_STOCKS

    Securities on the stock exchange

    Lots * ContractSize * LastPrice * MarginRate

    SYMBOL_CALC_MODE_EXCH_STOCKS_MOEX

    Securities on MOEX

    Lots * ContractSize * LastPrice * MarginRate

    SYMBOL_CALC_MODE_FUTURES

    Futures

    Lots * InitialMargin * MarginRate

    SYMBOL_CALC_MODE_EXCH_FUTURES

    Futures on the stock exchange

    Lots * InitialMargin * MarginRate               or
    Lots * MaintenanceMargin * MarginRate

    SYMBOL_CALC_MODE_EXCH_FUTURES_FORTS

    Futures on FORTS

    Lots * InitialMargin * MarginRate               or
    Lots * MaintenanceMargin * MarginRate

    SYMBOL_CALC_MODE_EXCH_BONDS

    Bonds on the stock exchange

    Lots * ContractSize * FaceValue * OpenPrice / 100

    SYMBOL_CALC_MODE_EXCH_BONDS_MOEX

    Bonds on MOEX

    Lots * ContractSize * FaceValue * OpenPrice / 100

    SYMBOL_CALC_MODE_SERV_COLLATERAL

    Non-tradable asset (margin not applicable)


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

        def order_calc_margin(self, action: int, symbol: str, volume: float, price: float) -> float:
            """
            Return margin in the account currency to perform a specified trading operation.
            
            """
    
            if volume <= 0 or price <= 0:
                self.__GetLogger().error("order_calc_margin failed: invalid volume or price")
                return 0.0
    
            if not self.IS_TESTER:
                try:
                    return round(self.mt5_instance.order_calc_margin(action, symbol, volume, price), 2)
                except Exception:
                    self.__GetLogger().warning(f"Failed: MT5 Error = {self.mt5_instance.last_error()}")
                    return 0.0
    
            # IS_TESTER = True
            sym = self.symbol_info(symbol)
    
            contract_size = sym.trade_contract_size
            leverage = max(self.AccountInfo.leverage, 1)
    
            margin_rate = (
                sym.margin_initial
                if sym.margin_initial > 0
                else sym.margin_maintenance
            )
            
            if margin_rate <= 0: # if margin rate is zero set it to 1
                margin_rate = 1.0
    
            mode = sym.trade_calc_mode
    
            if mode == self.mt5_instance.SYMBOL_CALC_MODE_FOREX:
                margin = (volume * contract_size * price) / leverage
    
            elif mode == self.mt5_instance.SYMBOL_CALC_MODE_FOREX_NO_LEVERAGE:
                margin = volume * contract_size * price
    
            elif mode in (
                self.mt5_instance.SYMBOL_CALC_MODE_CFD,
                self.mt5_instance.SYMBOL_CALC_MODE_CFDINDEX,
                self.mt5_instance.SYMBOL_CALC_MODE_EXCH_STOCKS,
                self.mt5_instance.SYMBOL_CALC_MODE_EXCH_STOCKS_MOEX,
            ):
                margin = volume * contract_size * price * margin_rate
    
            elif mode == self.mt5_instance.SYMBOL_CALC_MODE_CFDLEVERAGE:
                margin = (volume * contract_size * price * margin_rate) / leverage
    
            elif mode in (
                self.mt5_instance.SYMBOL_CALC_MODE_FUTURES,
                self.mt5_instance.SYMBOL_CALC_MODE_EXCH_FUTURES,
                # self.mt5_instance.SYMBOL_CALC_MODE_EXCH_FUTURES_FORTS,
            ):
                margin = volume * sym.margin_initial
    
            elif mode in (
                self.mt5_instance.SYMBOL_CALC_MODE_EXCH_BONDS,
                self.mt5_instance.SYMBOL_CALC_MODE_EXCH_BONDS_MOEX,
            ):
                margin = (
                    volume
                    * contract_size
                    * sym.trade_face_value
                    * price
                    / 100
                )
    
            elif mode == self.mt5_instance.SYMBOL_CALC_MODE_SERV_COLLATERAL:
                margin = 0.0
    
            else:
                self.__GetLogger().warning(f"Unknown calc mode {mode}, fallback margin formula used")
                margin = (volume * contract_size * price) / leverage
    
            return round(margin, 2)

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


    Заключительные мысли

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

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

    До связи.

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


    Вкладка вложений

    Название файла Описание и использование
    bars.py Содержит функции для сбора баров из клиента MetaTrader 5 в пользовательский файл и путь. 
    ticks.py Содержит функции для сбора тиков из клиента MetaTrader 5 в пользовательский файл и путь.
    config.py Python-файл конфигурации, где определены наиболее полезные переменные для повторного использования по всему проекту.
    utils.py Утилитный Python-файл, содержащий простые функции для помощи в различных задачах, то есть вспомогательные функции (helpers).
    simulator.py Содержит класс с именем Simulator. Основная логика нашего симулятора находится в одном месте.
    test.py Файл, используемый для тестирования всего кода и функций, рассмотренных в этой статье.
    error_description.py Содержит функции для преобразования всех кодов ошибок MetaTrader 5 в понятные человеку сообщения.
    requirements.txt  Содержит все Python-зависимости и их версии, используемые в этом проекте. 


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

    Прикрепленные файлы |
    Attachments.zip (18.65 KB)
    Построение моделей волатильности в MQL5 (Часть I): Первичная реализация Построение моделей волатильности в MQL5 (Часть I): Первичная реализация
    В этой статье мы представляем библиотеку MQL5 для моделирования волатильности, разработанную так, чтобы функционировать аналогично пакету arch в Python. В настоящее время библиотека поддерживает спецификацию распространённых моделей условного среднего: HAR, AR, Constant Mean и Zero Mean, а также моделей условной волатильности: Constant Variance, ARCH и GARCH.
    Разработка инструментария для анализа Price Action (Часть 43): Вероятностный анализ свечных паттернов и пробоев Разработка инструментария для анализа Price Action (Часть 43): Вероятностный анализ свечных паттернов и пробоев
    Улучшите рыночный анализ с помощью советника Candlestick Probability на MQL5 – компактного инструмента, который преобразует исходные ценовые бары в вероятностную аналитику в реальном времени по конкретному инструменту. Он классифицирует пин-бары, паттерны поглощения и доджи на закрытии бара, использует фильтрацию с учетом волатильности по ATR и при необходимости подтверждение пробоя. Советник рассчитывает простые и взвешенные по объему проценты отработки, помогая понять, каков типичный исход каждого паттерна на конкретных символах и таймфреймах. Маркеры на графике, компактная информационная панель и интерактивные переключатели позволяют быстро проверять результаты и сосредоточиться на нужном. Экспортируйте подробные CSV-логи для последующего анализа вне терминала. Используйте советник, чтобы строить вероятностные профили, оптимизировать стратегии и превращать распознавание паттернов в измеримое преимущество.
    Автоматизация торговых стратегий в MQL5 (Часть 26): Создание системы усреднения на основе пин-баров для многопозиционной торговли Автоматизация торговых стратегий в MQL5 (Часть 26): Создание системы усреднения на основе пин-баров для многопозиционной торговли
    В данной статье мы разрабатываем систему усреднения на основе пин-баров на языке MQL5, которая обнаруживает паттерны пин-баров для открытия сделок и использует стратегию усреднения для управления несколькими позициями, дополненную трейлинг-стопами и переводом в безубыток. Мы объединяем настраиваемые параметры с дашбордом для мониторинга позиций и прибыли в реальном времени.
    Разработка инструментария для анализа Price Action (Часть 42): Интерактивное тестирование на графике с кнопочной логикой и статистическими уровнями Разработка инструментария для анализа Price Action (Часть 42): Интерактивное тестирование на графике с кнопочной логикой и статистическими уровнями
    В мире, где важны скорость и точность, инструменты анализа должны быть столь же умными, как и рынки, на которых мы торгуем. В этой статье представлен советник с кнопочной логикой – интерактивная система, которая мгновенно преобразует исходные ценовые данные в значимые статистические уровни. Одним кликом мыши он вычисляет и отображает среднее, отклонение, процентили и другие показатели, превращая продвинутую аналитику в понятные сигналы на графике. Он выделяет зоны, где цена с наибольшей вероятностью отскочит, откатится или пробьет уровень, что делает анализ и быстрее, и практичнее.