English
preview
Паттерн "Репозиторий" в MQL5: Абстрагирование доступа к торговой истории для обеспечения тестируемости логики советника

Паттерн "Репозиторий" в MQL5: Абстрагирование доступа к торговой истории для обеспечения тестируемости логики советника

MetaTrader 5 — Статистика и анализ |
41 0
Ushana Kevin Iorkumbul
Ushana Kevin Iorkumbul

Введение

Аналитический модуль советника (EA), который напрямую вызывает функции HistorySelect(), HistoryDealGetDouble() и HistoryDealGetInteger() внутри своих методов расчёта, создаёт скрытые зависимости от состояния терминала MetaTrader 5. Например, такой метод, как CalculateWinRate(), выглядит как чистая функция. На практике для работы метода требуются активное соединение с брокером и загруженная история счета. Кроме того, требуется предварительный вызов функции HistorySelect() с указанием правильного диапазона дат. Если какое-либо из этих условий не выполняется, метод возвращает некорректные результаты, не генерируя при этом обнаруживаемой ошибки.

Такая сильная связанность создает серьезные ограничения при тестировании и сопровождении. Аналитический модуль, написанный таким образом, невозможно запустить изолированно. Для модульного тестирования метода CalculateWinRate() нужны работающий терминал и активное подключение. Также понадобится торговый счет с соответствующими историческими данными. Проверять граничные случаи, например пустую историю, в больших масштабах практически неосуществимо. Это вынуждает разработчика либо вручную очищать данные терминала, либо встраивать условную логику для тестирования в рабочий код.

Затраты на сопровождение нарастают по мере роста кодовой базы:

  • Изменения в API: если брокер изменяет свою схему классификации сделок, каждый метод расчета, использующий функцию HistoryDealGetInteger(), необходимо обновить вручную.
  • Препятствия для переноса: перенос советника к другому брокеру или на другую архитектуру требует отделения аналитического кода от слоя доступа к данным.
  • Дублирование кода: новые модули должны либо дублировать схему извлечения данных, либо опираться на недокументированное предположение о том, что другой модуль уже инициализировал состояние истории.

Сравнение архитектур до и после применения принципа инверсии зависимостей

Рисунок 1: Применение принципа инверсии зависимостей. Аналитические компоненты-потребители теперь зависят от абстракции ITradeRepository, а не от конкретных источников данных, что позволяет использовать взаимозаменяемые реализации рабочего и тестового репозиториев.

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


Паттерн "Репозиторий" как механизм инверсии зависимостей

Паттерн "Репозиторий" вводит слой контрактов между потребителями и источниками данных. Потребители зависят от интерфейса. Интерфейс определяет, какие операции с данными доступны. Конкретные реализации выполняют эти операции посредством взаимодействия со своим базовым источником данных. Потребитель не знает о конкретной реализации и не зависит от неё.

В MQL5 эта структура реализована с помощью абстрактного базового класса с чисто виртуальными методами. Каждый аналитический компонент получает указатель на ITradeRepository, а не указатель на какую-либо конкретную реализацию. При создании аналитических компонентов советник внедряет в них либо CLiveTradeRepository, либо CMockTradeRepository. Аналитический код в обоих случаях одинаков. Результаты одинаковы, если тестовые данные сформированы так, чтобы совпадать с данными рабочего репозитория. Механизм доступа к исходным данным совершенно иной.

Структура STradeRecord

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

//+------------------------------------------------------------------+
//|                                                  TradeRecord.mqh |
//| STradeRecord: canonical trade record struct shared across all    |
//| repository implementations and analytics consumers.              |
//+------------------------------------------------------------------+
#ifndef TRADERECORD_MQH
#define TRADERECORD_MQH
//+------------------------------------------------------------------+
//| STradeRecord                                                     |
//| Purpose: Core data structure representing a finalized trade deal |
//+------------------------------------------------------------------+
struct STradeRecord
  {
   ulong             ticket;        // Deal or order ticket identifier
   datetime          open_time;     // Trade open timestamp
   datetime          close_time;    // Trade close timestamp
   double            open_price;    // Entry price
   double            close_price;   // Exit price
   double            volume;        // Executed volume in lots
   double            profit;        // Net profit including swap and commission
   double            commission;    // Commission charged by broker
   double            swap;          // Swap charged or credited
   string            symbol;        // Trading instrument symbol
   int               direction;     // 1 = long, -1 = short
   string            comment;       // Order or deal comment
   //--- Constructor initializing all fields safely to default clear states
                     STradeRecord(void)
      :              ticket(0),
         open_time(0),
         close_time(0),
         open_price(0.0),
         close_price(0.0),
         volume(0.0),
         profit(0.0),
         commission(0.0),
         swap(0.0),
         symbol(""),
         direction(0),
         comment("")
     {
      //--- Empty structural body
     }
  };
#endif // TRADERECORD_MQH
//+------------------------------------------------------------------+

Конструктор инициализирует все поля безопасными значениями по умолчанию. Это важно, поскольку метод GetClosedTrade() в обеих реализациях репозитория возвращает объект STradeRecord, созданный конструктором по умолчанию, при недопустимом индексе или неудачной выборке истории. Вызывающий код получает обнуленную запись вместо чтения мусорных данных из памяти.

Интерфейс ITradeRepository

ITradeRepository — точка инверсии зависимостей. Это абстрактный базовый класс с чисто виртуальными методами. Ни один потребитель в этой архитектуре не хранит указатель непосредственно на CLiveTradeRepository или CMockTradeRepository. Каждый потребитель хранит указатель типа ITradeRepository*. Именно благодаря этому замена рабочего репозитория тестовым сводится к одному переназначению указателя, а не к рефакторингу всей кодовой базы.

//+------------------------------------------------------------------+
//|                                           ITradeRepository.mqh   |
//| Abstract repository contract. All analytics consumers depend     |
//| exclusively on this interface, never on concrete                 |
//| implementations or the History API directly.                     |
//+------------------------------------------------------------------+
#ifndef ITRADE_REPOSITORY_MQH
#define ITRADE_REPOSITORY_MQH
#include "TradeRecord.mqh"
//+------------------------------------------------------------------+
//| ITradeRepository                                                 |
//| Purpose: Interface defining data access contracts for historical |
//|          and transactional trade tracking operations.            |
//+------------------------------------------------------------------+
class ITradeRepository
  {
public:
   //--- Interface methods for record retrieval and counting
   virtual int          GetTradeCount(void) = 0;
   virtual STradeRecord GetClosedTrade(int index) = 0;
   //--- Interface methods for metric calculations and diagnostic evaluation
   virtual double       GetDailyPnL(datetime date) = 0;
   virtual double       GetWinRate(void) = 0;
   virtual double       GetTotalProfit(void) = 0;
   virtual double       GetMaxDrawdown(void) = 0;
   virtual double       GetAverageTrade(void) = 0;
   //--- Interface metadata identity and memory lifecycle management
   virtual string       GetRepositoryType(void) = 0;
   virtual             ~ITradeRepository(void) {}
  };
#endif // ITRADE_REPOSITORY_MQH
//+------------------------------------------------------------------+

Семь виртуальных методов определяют функциональные возможности репозитория. Синтаксис = 0 обеспечивает соблюдение контракта: любой класс, наследующий ITradeRepository, но не реализующий все семь методов, не может быть скомпилирован. Метод GetRepositoryType() возвращает "LIVE" или "MOCK" и используется в строке заголовка панели кривой средств, чтобы показать, какой источник данных активен в момент отрисовки.

Архитектурные слои

Слой Зона ответственности
Логика советника Принятие решений, связывание компонентов, внедрение репозитория
Интерфейс репозитория (ITradeRepository) Контракт абстракции; определяет доступные операции
Конкретная реализация репозитория Извлечение данных из API торговой истории или тестового набора данных
Источник данных API торговой истории, массив в памяти, файл CSV или база данных

CLiveTradeRepository: реализация контракта с использованием API торговой истории

CLiveTradeRepository реализует интерфейс ITradeRepository, обращаясь к API торговой истории MetaTrader 5 при каждом вызове метода. Конструктор принимает диапазон дат и фильтр по магическому номеру. Закрытый вспомогательный метод SelectHistory() вызывает HistorySelect(), чтобы заполнить кэш сделок терминала до начала перебора. Закрытый вспомогательный метод IsEntryDeal() исключает открывающие сделки, оставляя только записи DEAL_ENTRY_OUT и DEAL_ENTRY_INOUT, соответствующие завершению сделок.

//+------------------------------------------------------------------+
//|                                       LiveTradeRepository.mqh    |
//| CLiveTradeRepository: implements ITradeRepository using the      |
//| MetaTrader 5 History API. Queries HistorySelect() on each        |
//| method call to reflect current terminal trade history state.     |
//+------------------------------------------------------------------+
#ifndef LIVETRADE_REPOSITORY_MQH
#define LIVETRADE_REPOSITORY_MQH

#include "ITradeRepository.mqh"

//+------------------------------------------------------------------+
//| CLiveTradeRepository                                             |
//| Purpose: Active trading terminal history engine adapter tracking |
//|          deal entries, commissions, and metrics dynamically.     |
//+------------------------------------------------------------------+
class CLiveTradeRepository : public ITradeRepository
  {
private:
   datetime          m_from_date;    // History query start date boundary
   datetime          m_to_date;      // History query end date boundary
   ulong             m_magic;        // Filter: only include deals with this magic number (0 = all)

   //--- Internal utility validation rules
   bool              SelectHistory(void);
   bool              IsEntryDeal(ulong ticket);

public:
   //--- Lifecycle management
                     CLiveTradeRepository(datetime from_date, datetime to_date, ulong magic);
                    ~CLiveTradeRepository(void) {}

   //--- Overridden calculation and data access methods
   virtual int          GetTradeCount(void);
   virtual STradeRecord GetClosedTrade(int index);
   virtual double       GetDailyPnL(datetime date);
   virtual double       GetWinRate(void);
   virtual double       GetTotalProfit(void);
   virtual double       GetMaxDrawdown(void);
   virtual double       GetAverageTrade(void);
   virtual string       GetRepositoryType(void);
  };

Конструктор просто сохраняет три параметра, которые определяют область действия каждого последующего запроса:

//+------------------------------------------------------------------+
//| Constructor                                                      |
//| Purpose: Initializes the history adapter context boundaries      |
//+------------------------------------------------------------------+
CLiveTradeRepository::CLiveTradeRepository(datetime from_date,
      datetime to_date,
      ulong magic)
   : m_from_date(from_date),
     m_to_date(to_date),
     m_magic(magic)
  {
  }

SelectHistory() и IsEntryDeal() — единственные два метода во всей кодовой базе, которые напрямую взаимодействуют с API торговой истории. Каждый открытый метод делегирует логику фильтрации этим двум вспомогательным методам, поэтому любые будущие изменения в выборе истории или классификации сделок по типу входа/выхода затронут ровно два закрытых метода и никакой другой код:

//+------------------------------------------------------------------+
//| IsEntryDeal                                                      |
//| Purpose: Detects whether a historical deal record represents an  |
//|          a closing execution that completes the trade            |
//+------------------------------------------------------------------+
bool CLiveTradeRepository::IsEntryDeal(ulong ticket)
  {
//--- Retrieve structural transaction tracking type identifier
   long deal_entry = HistoryDealGetInteger(ticket, DEAL_ENTRY);

//--- DEAL_ENTRY_OUT (Close), DEAL_ENTRY_INOUT (Reversal) indicate trade completion
   return(deal_entry == DEAL_ENTRY_OUT || deal_entry == DEAL_ENTRY_INOUT);
  }

Метод GetTradeCount() демонстрирует общую схему работы всех открытых методов: сначала проверяется успешность выборки истории, затем выполняется перебор с фильтрацией по магическому номеру и типу входа/выхода на каждой итерации:

//+------------------------------------------------------------------+
//| GetTradeCount                                                    |
//| Purpose: Evaluates total number of qualifying closed loop deals  |
//+------------------------------------------------------------------+
int CLiveTradeRepository::GetTradeCount(void)
  {
//--- Synchronize cache storage lists
   if(!SelectHistory())
     {
      return(0);
     }

   int count = 0;
   int total = HistoryDealsTotal();

//--- Loop through the selection index to parse metadata matches
   for(int i = 0; i < total; i++)
     {
      ulong ticket = HistoryDealGetTicket(i);
      if(ticket == 0)
        {
         continue;
        }

      //--- Validate magic number ownership constraints if required
      if(m_magic > 0 && (ulong)HistoryDealGetInteger(ticket, DEAL_MAGIC) != m_magic)
        {
         continue;
        }

      //--- Filter non-closing transaction fragments
      if(!IsEntryDeal(ticket))
        {
         continue;
        }
      count++;
     }

   return(count);
  }

Метод GetClosedTrade() сопоставляет виртуальный порядковый индекс со сделкой в отфильтрованной последовательности и заполняет структуру STradeRecord. Такие потребители, как CEquityCurvePanel, вызывают этот метод в последовательном цикле с индексом от 0 до GetTradeCount() - 1, не зная, что исходные данные поступают из кэша сделок терминала:

//+------------------------------------------------------------------+
//| GetClosedTrade                                                   |
//| Purpose: Hydrates and builds a canonical structural trade model  |
//|          matching a virtual tracking positional index reference  |
//+------------------------------------------------------------------+
STradeRecord CLiveTradeRepository::GetClosedTrade(int index)
  {
   STradeRecord record;

//--- Check active collection environment state
   if(!SelectHistory())
     {
      return(record);
     }

   int total     = HistoryDealsTotal();
   int match_idx = 0;

//--- Traverse target tracking database
   for(int i = 0; i < total; i++)
     {
      ulong ticket = HistoryDealGetTicket(i);
      if(ticket == 0)
        {
         continue;
        }
      if(m_magic > 0 && (ulong)HistoryDealGetInteger(ticket, DEAL_MAGIC) != m_magic)
        {
         continue;
        }
      if(!IsEntryDeal(ticket))
        {
         continue;
        }

      //--- Extract and copy target values upon index verification match
      if(match_idx == index)
        {
         record.ticket      = ticket;
         record.close_time  = (datetime)HistoryDealGetInteger(ticket, DEAL_TIME);
         record.close_price = HistoryDealGetDouble(ticket, DEAL_PRICE);
         record.volume      = HistoryDealGetDouble(ticket, DEAL_VOLUME);
         record.profit      = HistoryDealGetDouble(ticket, DEAL_PROFIT);
         record.commission  = HistoryDealGetDouble(ticket, DEAL_COMMISSION);
         record.swap        = HistoryDealGetDouble(ticket, DEAL_SWAP);
         record.symbol      = HistoryDealGetString(ticket, DEAL_SYMBOL);
         record.comment     = HistoryDealGetString(ticket, DEAL_COMMENT);

         //--- Map system enum layouts to direction standards (1=Long, -1=Short)
         long deal_type   = HistoryDealGetInteger(ticket, DEAL_TYPE);
         record.direction = (deal_type == DEAL_TYPE_SELL) ? 1 : -1;
         return(record);
        }

      match_idx++;
     }

   return(record);
  }

Остальные методы расчета показателей — GetDailyPnL(), GetWinRate(), GetTotalProfit(), GetMaxDrawdown() и GetAverageTrade() — используют ту же схему перебора. При расчёте чистого результата каждый из них учитывает прибыль, комиссию и своп, чтобы отразить брокерские издержки. Метод GetAverageTrade() делегирует вычисления методам GetTradeCount() и GetTotalProfit(), вместо того чтобы перебирать историю в третий раз:

//+------------------------------------------------------------------+
//| GetWinRate                                                       |
//| Purpose: Calculates percentage ratio of net profitable trades    |
//+------------------------------------------------------------------+
double CLiveTradeRepository::GetWinRate(void)
  {
   if(!SelectHistory())
     {
      return(0.0);
     }

   int total = HistoryDealsTotal();
   int wins  = 0;
   int count = 0;

   for(int i = 0; i < total; i++)
     {
      ulong ticket = HistoryDealGetTicket(i);
      if(ticket == 0)
        {
         continue;
        }
      if(m_magic > 0 && (ulong)HistoryDealGetInteger(ticket, DEAL_MAGIC) != m_magic)
        {
         continue;
        }
      if(!IsEntryDeal(ticket))
        {
         continue;
        }

      //--- Calculate complete returns by factoring charges and adjustments
      double profit = HistoryDealGetDouble(ticket, DEAL_PROFIT) +
                      HistoryDealGetDouble(ticket, DEAL_COMMISSION) +
                      HistoryDealGetDouble(ticket, DEAL_SWAP);
      if(profit > 0.0)
        {
         wins++;
        }
      count++;
     }

//--- Protect against division by zero errors
   if(count == 0)
     {
      return(0.0);
     }
   return((wins / (double)count) * 100.0);
  }

//+------------------------------------------------------------------+
//| GetTotalProfit                                                   |
//| Purpose: Accumulates overall lifetime transaction metrics        |
//+------------------------------------------------------------------+
double CLiveTradeRepository::GetTotalProfit(void)
  {
   if(!SelectHistory())
     {
      return(0.0);
     }

   int    total  = HistoryDealsTotal();
   double result = 0.0;

   for(int i = 0; i < total; i++)
     {
      ulong ticket = HistoryDealGetTicket(i);
      if(ticket == 0)
        {
         continue;
        }
      if(m_magic > 0 && (ulong)HistoryDealGetInteger(ticket, DEAL_MAGIC) != m_magic)
        {
         continue;
        }
      if(!IsEntryDeal(ticket))
        {
         continue;
        }

      //--- Continuously increment master balances with comprehensive tracking values
      result += HistoryDealGetDouble(ticket, DEAL_PROFIT);
      result += HistoryDealGetDouble(ticket, DEAL_COMMISSION);
      result += HistoryDealGetDouble(ticket, DEAL_SWAP);
     }

   return(result);
  }

//+------------------------------------------------------------------+
//| GetMaxDrawdown                                                   |
//| Purpose: Tracks systemic structural peak reductions to measure   |
//|          maximum depth relative to history curves                |
//+------------------------------------------------------------------+
double CLiveTradeRepository::GetMaxDrawdown(void)
  {
   if(!SelectHistory())
     {
      return(0.0);
     }

   int    total  = HistoryDealsTotal();
   double equity = 0.0;
   double peak   = 0.0;
   double max_dd = 0.0;

   for(int i = 0; i < total; i++)
     {
      ulong ticket = HistoryDealGetTicket(i);
      if(ticket == 0)
        {
         continue;
        }
      if(m_magic > 0 && (ulong)HistoryDealGetInteger(ticket, DEAL_MAGIC) != m_magic)
        {
         continue;
        }
      if(!IsEntryDeal(ticket))
        {
         continue;
        }

      //--- Calculate current incremental closed balance level points
      equity += HistoryDealGetDouble(ticket, DEAL_PROFIT) +
                HistoryDealGetDouble(ticket, DEAL_COMMISSION) +
                HistoryDealGetDouble(ticket, DEAL_SWAP);

      //--- Update maximum running curve historical peak metrics
      if(equity > peak)
        {
         peak = equity;
        }

      //--- Evaluate distance drops between peak caps and raw floors
      double dd = peak - equity;
      if(dd > max_dd)
        {
         max_dd = dd;
        }
     }

   return(max_dd);
  }

//+------------------------------------------------------------------+
//| GetAverageTrade                                                  |
//| Purpose: Determines statistical math expectation value levels     |
//+------------------------------------------------------------------+
double CLiveTradeRepository::GetAverageTrade(void)
  {
   int count = GetTradeCount();
   if(count == 0)
     {
      return(0.0);
     }

//--- Mathematical mean logic processing standard returns
   return(GetTotalProfit() / count);
  }

//+------------------------------------------------------------------+
//| GetRepositoryType                                                |
//| Purpose: Explicit indicator identifying implementation lineage   |
//+------------------------------------------------------------------+
string CLiveTradeRepository::GetRepositoryType(void)
  {
   return("LIVE");
  }

#endif // LIVETRADE_REPOSITORY_MQH

Методы интерфейса репозитория

Метод Тип возвращаемого значения Назначение
GetTradeCount() int Возвращает общее количество закрытых сделок в наборе данных
GetClosedTrade(int index) STradeRecord Возвращает запись о сделке по указанному индексу
GetDailyPnL(datetime date) double Возвращает чистую прибыль/убыток по сделкам, закрытым в указанный день
GetWinRate() double Возвращает долю прибыльных сделок в процентах
GetTotalProfit() double Возвращает сумму прибылей по всем сделкам
GetMaxDrawdown() double Возвращает максимальную просадку средств от пика до последующего минимума в наборе данных
GetAverageTrade() double Возвращает среднюю прибыль на сделку

Реализации репозиториев

Репозиторий Источник данных Основной сценарий использования
CLiveTradeRepository API торговой истории (HistorySelect, HistoryDealGetDouble) Советник для эксплуатации на реальном или демонстрационном счете
CMockTradeRepository Массив STradeRecord в памяти с жестко заданными данными Офлайн-тестирование, конвейеры непрерывной интеграции, проверка параметров
Будущий репозиторий на основе CSV-файлов Файловая система через FileOpen / FileReadString Воспроизведение результатов бэктестинга, межсессионный анализ
Будущий репозиторий на основе базы данных Внешнее хранилище через WebRequest Облачная аналитика, агрегация данных по нескольким счетам

CMockTradeRepository: реализация контракта с использованием массива в памяти

CMockTradeRepository реализует тот же интерфейс без единого вызова API торговой истории. Его хранилище данных представляет собой фиксированный массив STradeRecord, который заполняется при создании объекта методом BuildDataset(). Каждый вызов метода тестового репозитория возвращает один и тот же результат при одинаковых входных данных на любом компьютере, в любом сеансе и у любого брокера. Именно это делает его полезным для тестирования.

//+------------------------------------------------------------------+
//|                                       MockTradeRepository.mqh    |
//| CMockTradeRepository: implements ITradeRepository via a          |
//| hardcoded in-memory STradeRecord array. Produces deterministic   |
//| results independent of terminal state or broker connection.      |
//+------------------------------------------------------------------+
#ifndef MOCKTRADE_REPOSITORY_MQH
#define MOCKTRADE_REPOSITORY_MQH

#include "ITradeRepository.mqh"

//+------------------------------------------------------------------+
//| CMockTradeRepository                                             |
//| Purpose: Mock database provider serving a static historical deal |
//|          matrix for isolated strategic analysis testing.         |
//+------------------------------------------------------------------+
class CMockTradeRepository : public ITradeRepository
  {
private:
   STradeRecord      m_trades[];     // In-memory trade dataset
   int               m_count;        // Number of records in dataset

   void              BuildDataset(void);

public:
   //--- Lifecycle Management
                     CMockTradeRepository(void);
                    ~CMockTradeRepository(void) {}

   //--- Interface Implementations
   virtual int          GetTradeCount(void);
   virtual STradeRecord GetClosedTrade(int index);
   virtual double       GetDailyPnL(datetime date);
   virtual double       GetWinRate(void);
   virtual double       GetTotalProfit(void);
   virtual double       GetMaxDrawdown(void);
   virtual double       GetAverageTrade(void);
   virtual string       GetRepositoryType(void);
  };

Метод BuildDataset() формирует набор данных из 48 сделок, включающий 29 прибыльных и 19 убыточных. Значения прибыли, торговые символы и временные метки детерминированы. К каждой сделке применяется комиссия в размере -0,70, чтобы показатели отражали результат с учётом издержек, а не валовую прибыль. Набор данных сформирован таким образом, что доля прибыльных сделок составляет примерно 60,42%:

//+-------------------------------------------------------------------+
//| BuildDataset                                                      |
//| Purpose: Constructs a fixed 48-trade dataset matching structural  |
//|          sample criteria (Win Rate 60.42%, Count: 48)             |
//+-------------------------------------------------------------------+
void CMockTradeRepository::BuildDataset(void)
  {
   //--- Profit values: 29 winners, 19 losers, net 344.20 on a base day
   double profits[] =
     {
      18.50,  -12.30,  25.70,  -8.90,  31.20,  -15.40,  22.10,  -9.80,
      27.30,   14.60, -11.20,  19.80, -22.50,  35.40,  -13.70,  28.90,
       8.40,  -17.60,  23.50,  16.20, -10.30,  29.70,  -14.80,  21.40,
      12.90,  -19.20,  33.60,   7.80, -11.90,  26.10,  -16.50,  18.70,
      24.30,  -13.10,  30.80,   9.20, -20.40,  22.90,  -8.60,   15.40,
      28.50,  -12.70,  19.30,  -9.40,  34.20,  -17.80,  11.60,  25.90
     };

   string symbols[] =
     {
      "EURUSD", "GBPUSD", "USDJPY", "AUDUSD", "USDCAD", "EURUSD", "GBPUSD", "USDJPY",
      "EURUSD", "AUDUSD", "USDCAD", "GBPUSD", "EURUSD", "USDJPY", "AUDUSD", "EURUSD",
      "GBPUSD", "USDCAD", "EURUSD", "USDJPY", "GBPUSD", "EURUSD", "AUDUSD", "USDCAD",
      "USDJPY", "EURUSD", "GBPUSD", "AUDUSD", "USDCAD", "EURUSD", "USDJPY", "GBPUSD",
      "EURUSD", "AUDUSD", "USDCAD", "USDJPY", "EURUSD", "GBPUSD", "AUDUSD", "EURUSD",
      "USDJPY", "USDCAD", "GBPUSD", "EURUSD", "AUDUSD", "USDJPY", "EURUSD", "GBPUSD"
     };

   //--- Configure internal array dimensions
   m_count = ArraySize(profits);
   ArrayResize(m_trades, m_count);

   //--- Base date reference: Midnight of 2024-01-15 
   datetime base_day = D'2024.01.15 00:00';

   //--- Synthesize comprehensive historical metadata loops
   for(int i = 0; i < m_count; i++)
     {
      m_trades[i].ticket      = (ulong)(100001 + i);
      m_trades[i].open_time   = base_day + (i * 600);
      m_trades[i].close_time  = base_day + (i * 600) + 300;
      m_trades[i].open_price  = 1.08000 + (i * 0.00010);
      m_trades[i].close_price = m_trades[i].open_price + (profits[i] > 0 ? 0.00050 : -0.00050);
      m_trades[i].volume      = 0.10;
      m_trades[i].profit      = profits[i];
      m_trades[i].commission  = -0.70;
      m_trades[i].swap        = 0.0;
      m_trades[i].symbol      = symbols[i];
      m_trades[i].direction   = (profits[i] > 0 && i % 2 == 0) ? 1 : -1;
      m_trades[i].comment     = "Mock trade " + IntegerToString(i + 1);
     }
  }

Поскольку тестовый репозиторий владеет данными, хранящимися в обычном массиве, работа метода GetClosedTrade() сводится к проверке границ индекса и прямому чтению из массива. Нет ни перебора элементов, ни вызовов API, ни возможности изменения результата между вызовами:

//+------------------------------------------------------------------+
//| GetClosedTrade                                                   |
//+------------------------------------------------------------------+
STradeRecord CMockTradeRepository::GetClosedTrade(int index)
  {
   //--- Validate boundaries to safeguard against array out-of-range errors
   if(index < 0 || index >= m_count)
     {
      STradeRecord empty_record;
      return(empty_record);
     }
     
   return(m_trades[index]);
  }

Методы расчета показателей выполняют перебор непосредственно по массиву m_trades[]. Метод GetWinRate() использует ту же логику расчета результата с учетом издержек, что и реализация рабочего репозитория (прибыль плюс комиссия плюс своп), поэтому оба репозитория выдают сопоставимые значения при одинаковых наборах данных:

//+------------------------------------------------------------------+
//| GetWinRate                                                       |
//+------------------------------------------------------------------+
double CMockTradeRepository::GetWinRate(void)
  {
   if(m_count == 0)
     {
      return(0.0);
     }

   int wins = 0;
   for(int i = 0; i < m_count; i++)
     {
      //--- Evaluate trade result net of operational overhead costs
      double net = m_trades[i].profit + 
                   m_trades[i].commission + 
                   m_trades[i].swap;
      if(net > 0.0)
        {
         wins++;
        }
     }

   return((wins / (double)m_count) * 100.0);
  }

//+------------------------------------------------------------------+
//| GetTotalProfit                                                   |
//+------------------------------------------------------------------+
double CMockTradeRepository::GetTotalProfit(void)
  {
   double total = 0.0;
   
   for(int i = 0; i < m_count; i++)
     {
      total += m_trades[i].profit + 
               m_trades[i].commission + 
               m_trades[i].swap;
     }
     
   return(total);
  }

//+------------------------------------------------------------------+
//| GetMaxDrawdown                                                   |
//| Purpose: Processes the continuous data grid to locate the widest |
//|          peak-to-trough drop valley in the cumulative equity path|
//+------------------------------------------------------------------+
double CMockTradeRepository::GetMaxDrawdown(void)
  {
   double equity = 0.0;
   double peak   = 0.0;
   double max_dd = 0.0;

   for(int i = 0; i < m_count; i++)
     {
      equity += m_trades[i].profit + 
                m_trades[i].commission + 
                m_trades[i].swap;

      if(equity > peak)
        {
         peak = equity;
        }
        
      double dd = peak - equity;
      if(dd > max_dd)
        {
         max_dd = dd;
        }
     }

   return(max_dd);
  }

//+------------------------------------------------------------------+
//| GetAverageTrade                                                  |
//+------------------------------------------------------------------+
double CMockTradeRepository::GetAverageTrade(void)
  {
   if(m_count == 0)
     {
      return(0.0);
     }
     
   return(GetTotalProfit() / m_count);
  }

//+------------------------------------------------------------------+
//| GetRepositoryType                                                |
//+------------------------------------------------------------------+
string CMockTradeRepository::GetRepositoryType(void)
  {
   return("MOCK");
  }

#endif // MOCKTRADE_REPOSITORY_MQH
//+------------------------------------------------------------------+


Независимость модулей-потребителей

Каждый аналитический модуль-потребитель в этой архитектуре зависит только от ITradeRepository*. Модуль управления рисками использует этот интерфейс для получения результатов недавних сделок, необходимых для расчета размера позиции. Аналитический модуль использует этот интерфейс для расчета доли прибыльных сделок, средней прибыли на сделку и просадки. Панель кривой средств использует этот интерфейс для последовательного перебора всех сделок и построения кривой накопленной прибыли. Ни один из этих модулей не содержит ни одного вызова функций API торговой истории.

Модули-потребители

Модуль Зависит от Используемые операции с данными
CAnalyticsEngine ITradeRepository* GetWinRate(), GetTotalProfit(), GetAverageTrade(), GetMaxDrawdown()
CEquityCurvePanel ITradeRepository* GetTradeCount(), GetClosedTrade()
Модуль управления рисками (уровень советника) ITradeRepository* GetDailyPnL(), GetWinRate()
Модуль расчета размера позиции ITradeRepository* GetAverageTrade(), GetMaxDrawdown()

CAnalyticsEngine: использование интерфейса

CAnalyticsEngine содержит единственное поле типа ITradeRepository*. Его конструктор принимает указатель, передаваемый извне, поэтому вызывающая сторона решает, будет ли аналитика работать с данными рабочего или тестового репозитория. Сам аналитический движок в этом выборе не участвует. Метод RunAnalysis() вызывает шесть методов репозитория и кэширует результаты. Метод PrintReport() форматирует эти кэшированные значения и выводит их в журнал терминала:

//+------------------------------------------------------------------+
//|                                              AnalyticsEngine.mqh |
//| CAnalyticsEngine: computes win rate, total profit, average       |
//| trade, and max drawdown exclusively through ITradeRepository*.   |
//| Contains no direct History API calls.                            |
//+------------------------------------------------------------------+
#ifndef ANALYTICSENGINE_MQH
#define ANALYTICSENGINE_MQH

#include "ITradeRepository.mqh"

//+------------------------------------------------------------------+
//| CAnalyticsEngine                                                 |
//| Purpose: Decoupled statistical processing engine evaluating      |
//|          metrics provided by an abstract historical interface.   |
//+------------------------------------------------------------------+
class CAnalyticsEngine
  {
private:
   ITradeRepository *m_repository;   // Non-owned data layer resource pointer

   double            m_win_rate;     // Cached value for last calculated win rate
   double            m_total_profit; // Cached value for last calculated net profit
   double            m_avg_trade;    // Cached value for last calculated average payout expectation
   double            m_max_drawdown; // Cached value for last calculated absolute maximum drawdown depth
   double            m_daily_pnl;    // Cached value for last calculated net day profit/loss
   int               m_trade_count;  // Cached value for last calculated closed trade loop count

public:
   //--- Lifecycle Management
                     CAnalyticsEngine(ITradeRepository *repository);
                    ~CAnalyticsEngine(void) {}

   //--- Processing Routines
   void              RunAnalysis(datetime daily_pnl_date);
   void              PrintReport(void);

   //--- State Constant Accessors
   double            GetWinRate(void)     const;
   double            GetTotalProfit(void) const;
   double            GetAvgTrade(void)    const;
   double            GetMaxDrawdown(void) const;
   double            GetDailyPnL(void)    const;
   int               GetTradeCount(void)  const;
  };

//+------------------------------------------------------------------+
//| Constructor                                                      |
//| Purpose: Injects database dependency and normalizes state values |
//+------------------------------------------------------------------+
CAnalyticsEngine::CAnalyticsEngine(ITradeRepository *repository)
   : m_repository(repository),
     m_win_rate(0.0),
     m_total_profit(0.0),
     m_avg_trade(0.0),
     m_max_drawdown(0.0),
     m_daily_pnl(0.0),
     m_trade_count(0)
  {
  }

//+------------------------------------------------------------------+
//| RunAnalysis                                                      |
//| Purpose: Polls and caches metric values across the repository    |
//+------------------------------------------------------------------+
void CAnalyticsEngine::RunAnalysis(datetime daily_pnl_date)
  {
   //--- Validate data layer reference to prevent access violations
   if(m_repository == NULL)
     {
      Print("[CAnalyticsEngine] Repository pointer is null. Analysis aborted.");
      return;
     }

   //--- Sequentially pull evaluation parameters safely from abstraction layer
   m_trade_count  = m_repository.GetTradeCount();
   m_win_rate     = m_repository.GetWinRate();
   m_total_profit = m_repository.GetTotalProfit();
   m_avg_trade    = m_repository.GetAverageTrade();
   m_max_drawdown = m_repository.GetMaxDrawdown();
   m_daily_pnl    = m_repository.GetDailyPnL(daily_pnl_date);
  }

//+-------------------------------------------------------------------------+
//| PrintReport                                                             |
//| Purpose: Formats metric results cleanly onto terminal diagnostic output |
//+-------------------------------------------------------------------------+
void CAnalyticsEngine::PrintReport(void)
  {
   //--- Dynamically isolate driver type name signature details
   string repo_type = (m_repository != NULL) ? m_repository.GetRepositoryType() : "UNKNOWN";

   Print("[INFO] Repository Type = " + repo_type);
   Print("[INFO] Running Analytics...");
   Print("");
   Print("Daily PnL    = " + DoubleToString(m_daily_pnl, 2));
   Print("Win Rate     = " + DoubleToString(m_win_rate, 2) + "%");
   Print("Trade Count  = " + IntegerToString(m_trade_count));
   Print("Total Profit = " + DoubleToString(m_total_profit, 2));
   Print("Avg Trade    = " + DoubleToString(m_avg_trade, 2));
   Print("Max Drawdown = " + DoubleToString(m_max_drawdown, 2));
   Print("");
  }

//+------------------------------------------------------------------+
//| GetWinRate                                                       |
//| Purpose: Returns the cached win rate percentage metric           |
//+------------------------------------------------------------------+
double CAnalyticsEngine::GetWinRate(void) const
  {
   return(m_win_rate);
  }

//+------------------------------------------------------------------+
//| GetTotalProfit                                                   |
//| Purpose: Returns the cached historical net profit value    |
//+------------------------------------------------------------------+
double CAnalyticsEngine::GetTotalProfit(void) const
  {
   return(m_total_profit);
  }

//+------------------------------------------------------------------+
//| GetAvgTrade                                                      |
//| Purpose: Returns the cached average profit math expectation value|
//+------------------------------------------------------------------+
double CAnalyticsEngine::GetAvgTrade(void) const
  {
   return(m_avg_trade);
  }

//+------------------------------------------------------------------+
//| GetMaxDrawdown                                                   |
//| Purpose: Returns the cached maximum absolute curve drawdown value|
//+------------------------------------------------------------------+
double CAnalyticsEngine::GetMaxDrawdown(void) const
  {
   return(m_max_drawdown);
  }

//+------------------------------------------------------------------+
//| GetDailyPnL                                                      |
//| Purpose: Returns the cached daily profit/loss for analyzed date  |
//+------------------------------------------------------------------+
double CAnalyticsEngine::GetDailyPnL(void) const
  {
   return(m_daily_pnl);
  }

//+------------------------------------------------------------------+
//| GetTradeCount                                                    |
//| Purpose: Returns the cached total qualifying closed trade count  |
//+------------------------------------------------------------------+
int CAnalyticsEngine::GetTradeCount(void) const
  {
   return(m_trade_count);
  }

#endif // ANALYTICSENGINE_MQH
//+------------------------------------------------------------------+

Метод RunAnalysis() не содержит ветвлений по типу репозитория. Он вызывает одни и те же шесть методов независимо от того, на какую реализацию ссылается указатель — на CLiveTradeRepository или на CMockTradeRepository. Таблица виртуальных методов обеспечивает диспетчеризацию вызовов во время выполнения. Именно на этом и основана архитектура: аналитический код, который полностью независим от источника данных.


Модель владения данными

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

CMockTradeRepository владеет своими торговыми данными, храня их внутри объекта в виде фиксированного массива структур STradeRecord. Массив заполняется при создании объекта и не изменяется в течение всего времени работы советника. Каждый вызов метода тестового репозитория возвращает один и тот же результат при одинаковых входных данных, что и является определением детерминированного источника данных.

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

Запрос доли прибыльных сделок без зависимости от источника данных

Рисунок 2: Запрос доли прибыльных сделок без зависимости от источника данных. CAnalyticsEngine вызывает метод GetWinRate() через интерфейс ITradeRepository и получает результат. Всё, что находится за интерфейсом (рабочий репозиторий и API торговой истории, через который он читает данные), скрыто от аналитического движка, поэтому источник данных можно заменить без изменения вызывающего кода.

Запрос доли прибыльных сделок у тестового репозитория

Рисунок 3: Запрос доли прибыльных сделок у тестового репозитория. Тот же CAnalyticsEngine выполняет тот же вызов GetWinRate() через ITradeRepository, но здесь CMockTradeRepository возвращает данные из массива в памяти. Здесь не требуется ни подключение терминала к брокеру, ни брокерский счёт, ни вызов API торговой истории, а скомпилированный код аналитического движка остается таким же, как в случае на рисунке 2. Изменилась лишь реализация, скрытая за интерфейсом.


Накладные расходы, связанные с полиморфной диспетчеризацией

Каждый вызов метода через ITradeRepository* требует обращения к таблице виртуальных методов: считывается адрес таблицы виртуальных методов объекта, на который указывает указатель, находится запись нужного метода, и выполнение переходит к функции, реализующей этот метод. Это требует двух операций чтения из памяти и одного непрямого перехода, что, как правило, занимает от трёх до пяти наносекунд при "разогретом" кэше.

Для аналитических вычислений, выполняющихся один раз в функции OnInit() или один раз при закрытии каждого бара, эти накладные расходы с точки зрения архитектуры не имеют значения. Для модуля управления рисками, который вызывает метод GetWinRate() на каждом тике волатильного инструмента, накладные расходы накапливаются. При частоте 100 тиков в секунду и затратах на диспетчеризацию в 5 наносекунд общие накладные расходы составляют 500 нс процессорного времени в секунду (~5e-7 с/с), что ниже уровня шума измерений для любого советника на MQL5.

Более значительные затраты связаны с самой абстракцией вызова метода. Цикл с прямыми вызовами HistoryDealGetDouble() вычисляет долю прибыльных сделок за один проход по списку сделок. Паттерн "Репозиторий" добавляет один уровень косвенности при каждом вызове метода. Методы GetWinRate(), GetTotalProfit() и GetMaxDrawdown() независимо друг от друга обходят список сделок. В результате рабочий репозиторий выполняет три обхода, тогда как в реализации с прямыми вызовами API их можно было бы объединить в один. Это архитектурный компромисс: преимущества устранения связанности оправдывают накладные расходы на обходы во всех сценариях аналитических вычислений, кроме наиболее чувствительных к задержкам.


Тестируемость и детерминированное моделирование граничных случаев

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

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

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


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

Класс CEquityCurvePanel последовательно перебирает все сделки из репозитория и с помощью CCanvas строит линию накопленной прибыли. Поскольку класс зависит только от ITradeRepository*, он одинаково строит график независимо от того, поступают ли данные с реального счёта или из тестового набора данных. Это демонстрируется в функции OnInit() советника, где кривая средств строится на основе тестовых данных еще до того, как потребуется подключение к брокеру.


Рисунок 4: Панель кривой средств, отрисованная с помощью CCanvas исключительно по данным тестового репозитория.

Рисунок 4: Панель кривой средств, отрисованная с помощью CCanvas исключительно по данным тестового репозитория.


CEquityCurvePanel: отображение кривой

CEquityCurvePanel использует CCanvas для построения кривой накопленной прибыли на графике. Его метод Render() принимает указатель типа ITradeRepository*, последовательно перебирает все сделки с помощью GetClosedTrade(), накапливает текущий результат сделок и передает полученный массив в DrawCurve(). Поскольку панель зависит только от интерфейса, она одинаково отображает данные как рабочего, так и тестового репозитория. В строке заголовка отображается надпись "Equity Curve — LIVE" или "Equity Curve — MOCK" в зависимости от результата вызова GetRepositoryType():

//+------------------------------------------------------------------------+
//| Render                                                                 |
//| Purpose: Updates internal buffers and prompts real-time redraw updates |
//+------------------------------------------------------------------------+
void CEquityCurvePanel::Render(ITradeRepository *repo)
  {
   if(!m_initialized || repo == NULL)
     {
      return;
     }

   int count = repo.GetTradeCount();
   if(count == 0)
     {
      return;
     }

   double equity[];
   ArrayResize(equity, count);

//--- Parse total net returns over individual asset classes
   double running = 0.0;
   for(int i = 0; i < count; i++)
     {
      STradeRecord rec = repo.GetClosedTrade(i);
      running      += rec.profit + rec.commission + rec.swap;
      equity[i]     = running;
     }

//--- DrawCurve calls DrawGrid internally which draws the title bar
//--- background band. After DrawCurve returns, repo is in scope so
//    the title text can be written on top of the band correctly.
   DrawCurve(equity, count);

//--- Write title text after DrawCurve so repo pointer is available.
//--- GetRepositoryType() returns "LIVE" or "MOCK" depending on which
//--- concrete implementation was injected at construction time.
   string title = "Equity Curve — " + repo.GetRepositoryType();
   m_canvas.TextOut(8, 4, title, ColorToARGB(m_text_color, 255));

   DrawLabels(repo);

   m_canvas.Update();
  }

Метод DrawLabels() напрямую вызывает методы GetWinRate(), GetAverageTrade() и GetMaxDrawdown() репозитория для заполнения сводной строки в нижней части панели. Здесь не требуются никакие значения из кэша CAnalyticsEngine. Панель самодостаточна:

//+----------------------------------------------------------------------+
//| DrawLabels                                                           |
//| Purpose: Prints core summary matrix indicators inside bottom margins |
//+----------------------------------------------------------------------+
void CEquityCurvePanel::DrawLabels(ITradeRepository *repo)
  {
   string label = "Win Rate: " + DoubleToString(repo.GetWinRate(), 2) + "%" +
                  "  |  Avg Trade: " + DoubleToString(repo.GetAverageTrade(), 2) +
                  "  |  Max DD: -" + DoubleToString(repo.GetMaxDrawdown(), 2);

   m_canvas.TextOut(8, m_height - 22, label, ColorToARGB(m_text_color, 200));
  }


Удобство сопровождения в долгосрочной перспективе

Преимущество паттерна "Репозиторий" с точки зрения удобства сопровождения можно оценить при смене источника данных. Чтобы добавить репозиторий на основе CSV, реализуйте интерфейс ITradeRepository в новом классе, который считывает данные из файла. Аналитический движок, панель кривой средств, модуль управления рисками и модуль расчета размера позиции продолжают работать без каких-либо изменений. Советник выбирает новый репозиторий при создании объектов: для этого достаточно изменить одну операцию присваивания указателя.

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


Объединение всех компонентов в советнике

В файле RepositoryPatternEA.mq5 создаются экземпляры всех компонентов и устанавливаются связи между ними. Оба репозитория создаются в динамической памяти в функции OnInit(). Активный репозиторий выбирается одним присваиванием указателю в зависимости от значения входного параметра inp_use_mock_repository. И аналитический модуль, и панель кривой средств получают только указатель типа ITradeRepository*. Когда параметр inp_run_both_repositories имеет значение true, советник запускает один и тот же аналитический код для обоих репозиториев и выводит оба набора результатов, демонстрируя, что формат вывода остается одинаковым независимо от источника данных:

//+------------------------------------------------------------------+
//| OnInit                                                           |
//| Purpose: Expert initialization function. Allocates repositories, |
//|          runs base benchmarking, and establishes panel views.    |
//+------------------------------------------------------------------+
int OnInit(void)
  {
   datetime now       = TimeCurrent();
   datetime from_date = now - (datetime)(inp_history_days * 86400);

   //--- Construct both repository implementations
   g_live_repo = new CLiveTradeRepository(from_date, now, inp_magic_filter);
   g_mock_repo = new CMockTradeRepository();

   if(CheckPointer(g_live_repo) != POINTER_DYNAMIC || CheckPointer(g_mock_repo) != POINTER_DYNAMIC)
     {
      Print("[RepositoryPatternEA] Failed to allocate repository instances.");
      return(INIT_FAILED);
     }

   //--- Select active repository based on input
   g_repository = inp_use_mock_repository ? (ITradeRepository *)g_mock_repo : (ITradeRepository *)g_live_repo;

   //--- Construct analytics engine bound to active repository
   g_analytics = new CAnalyticsEngine(g_repository);
   if(CheckPointer(g_analytics) != POINTER_DYNAMIC)
     {
      Print("[RepositoryPatternEA] Failed to allocate CAnalyticsEngine.");
      return(INIT_FAILED);
     }

   //--- Run analytics on the active repository
   datetime analysis_date = now - (now % 86400);
   g_analytics.RunAnalysis(analysis_date);

   if(inp_enable_repository_logs)
     {
      g_analytics.PrintReport();
     }

   //--- Optionally run both repositories with identical analytics code
   if(inp_run_both_repositories)
     {
      Print("=== LIVE REPOSITORY RESULTS ===");
      RunAndPrintAnalytics(g_live_repo, analysis_date);

      Print("=== MOCK REPOSITORY RESULTS ===");
      RunAndPrintAnalytics(g_mock_repo, analysis_date);
     }

   //--- Construct and render equity curve panel from mock repository
   //--- This demonstrates that the panel operates without terminal history
   g_panel = new CEquityCurvePanel(0, inp_panel_x, inp_panel_y, inp_panel_width, inp_panel_height);

   if(CheckPointer(g_panel) != POINTER_DYNAMIC)
     {
      Print("[RepositoryPatternEA] Failed to allocate CEquityCurvePanel.");
      return(INIT_FAILED);
     }

   if(!g_panel.Create())
     {
      Print("[RepositoryPatternEA] Failed to create equity curve canvas.");
      return(INIT_FAILED);
     }

   //--- Render equity curve from mock data: no broker connection required
   g_panel.Render(g_mock_repo);

   PrintFormat("[RepositoryPatternEA] Initialized. Active repository: %s | Lookback: %s days | Magic filter: %s",
               g_repository.GetRepositoryType(),
               IntegerToString(inp_history_days),
               (inp_magic_filter == 0) ? "ALL" : IntegerToString((int)inp_magic_filter));

   return(INIT_SUCCEEDED);
  }

Вызов g_panel.Render(g_mock_repo) в последней строке перед оператором return наглядно демонстрирует, что панель кривой средств не требует подключения к брокеру. Панель отрисовывается исключительно на основе набора данных в памяти при инициализации — до получения каких-либо рыночных данных и до первого вызова OnTick().

Функция OnDeinit() явно освобождает все динамически выделенные области памяти и обнуляет все указатели. Порядок имеет значение: сначала удаляется панель, поскольку она содержит ресурс CCanvas. Затем удаляется аналитический модуль. В последнюю очередь удаляются два объекта конкретных реализаций репозитория. Указателю на интерфейс g_repository присваивается значение null, но удаление через него не выполняется, поскольку он указывает на память, которой владеет один из двух указателей на конкретные реализации репозитория и которая уже освобождена через этот указатель:

//+------------------------------------------------------------------+
//| OnDeinit                                                         |
//| Purpose: Expert deinitialization function. Performs orderly state|
//|          cleanup and releases dynamic pointer memory trees.      |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
   //--- Destroy allocated dynamic instances explicitly to prevent leaks
   if(CheckPointer(g_panel) == POINTER_DYNAMIC)
     {
      delete g_panel;
      g_panel = NULL;
     }
   if(CheckPointer(g_analytics) == POINTER_DYNAMIC)
     {
      delete g_analytics;
      g_analytics = NULL;
     }
   if(CheckPointer(g_live_repo) == POINTER_DYNAMIC)
     {
      delete g_live_repo;
      g_live_repo = NULL;
     }
   if(CheckPointer(g_mock_repo) == POINTER_DYNAMIC)
     {
      delete g_mock_repo;
      g_mock_repo = NULL;
     }

   g_repository = NULL;

   PrintFormat("[RepositoryPatternEA] Deinitialized. Reason code: %d.", reason);
  }


Заключение

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

Паттерн "Репозиторий" решает эти проблемы, делая зависимости от данных явными и позволяя внедрять их через указатель ITradeRepository*. Аналитический уровень просто запрашивает данные. Для него совершенно неважно, поступают ли эти данные с работающего сервера брокера или из тестового набора данных.

Такое изменение архитектуры предполагает определенные компромиссы:

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

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


Программы, использованные в статье:

# Название Тип Описание
1 TradeRecord.mqh Подключаемый файл Структура STradeRecord, определяющая каноническую запись о сделке, используемую всеми реализациями репозитория и всеми потребителями
2 ITradeRepository.mqh Подключаемый файл Абстрактный базовый класс, определяющий контракт репозитория с чисто виртуальными методами для всех операций доступа к данным
3 LiveTradeRepository.mqh Подключаемый файл CLiveTradeRepository, реализующий контракт с помощью функции HistorySelect(), перебора сделок и расчета прибыли и убытка по данным терминала
4 MockTradeRepository.mqh Подключаемый файл CMockTradeRepository, реализующий контракт с помощью заданного в коде массива STradeRecord в памяти для детерминированного автономного тестирования
5 AnalyticsEngine.mqh Подключаемый файл CAnalyticsEngine, рассчитывающий долю прибыльных сделок, общую прибыль, среднюю прибыль на сделку и максимальную просадку исключительно через ITradeRepository*
6 EquityCurvePanel.mqh Подключаемый файл CEquityCurvePanel, отображающий с помощью CCanvas кривую средств по накопленному результату сделок на основе данных из репозитория
7 RepositoryPatternEA.mq5 Демонстрационный советник Демонстрационный советник, который в функции OnInit() подключает обе реализации репозитория к одному и тому же аналитическому модулю, выводит совпадающие результаты и строит кривую средств по тестовым данным
8 Repository_Pattern.zip Архив в формате ZIP ZIP-архив, содержащий все прикрепленные файлы и их пути относительно корневой папки терминала.

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

Прикрепленные файлы |
TradeRecord.mqh (2.02 KB)
Особенности написания Пользовательских Индикаторов Особенности написания Пользовательских Индикаторов
Написание пользовательских индикаторов в торговой системе MetaTrader 4
От скользящей средней с фиксированными параметрами к адаптивной фильтрации (Часть 2): Реализация индикатора SAMA_NLMS на MQL5 От скользящей средней с фиксированными параметрами к адаптивной фильтрации (Часть 2): Реализация индикатора SAMA_NLMS на MQL5
В этой статье самоадаптивная скользящая средняя (SAMA) на основе алгоритма NLMS реализована в виде работающего индикатора MQL5. Приведён полный исходный код и объяснены ключевые проектные решения, включая выполнение непосредственно в основном цикле, инициализацию весов одинаковыми значениями, обновление по закрытым барам и границы устойчивости. Также рассмотрены установка, использование и ограничения. Результат — скомпилированный индикатор SAMA_NLMS, готовый к установке на график, и понятная основа для последующего сравнительного тестирования с использованием советника.
Особенности написания экспертов Особенности написания экспертов
Написание и тестирование экспертов в торговой системе MetaTrader 4.
Нейросети в трейдинге: AlphaGen — поиск торговых факторов (генерация формул) Нейросети в трейдинге: AlphaGen — поиск торговых факторов (генерация формул)
Продолжаем адаптацию AlphaGen для MetaTrader 5. В первой части мы реализовали правила, определяющие допустимые продолжения формулы. Теперь разбираем, как генератор OpenCL применяет эти правила и по оценкам модели выбирает токены, а вычислитель проверяет готовое выражение и рассчитывает значения торгового фактора по барам. Показываем, как по статусу результата отличить корректный ноль от пропуска из-за нехватки истории и распознать ошибку вычисления.