English
preview
Отложенная загрузка хендлов индикаторов в MQL5: Менеджер ресурсов для мультитаймфреймовых советников

Отложенная загрузка хендлов индикаторов в MQL5: Менеджер ресурсов для мультитаймфреймовых советников

MetaTrader 5 — Торговые системы |
41 1
Ushana Kevin Iorkumbul
Ushana Kevin Iorkumbul

Введение

Мультитаймфреймовому торговому советнику (EA), торгующему на 10 символах и четырех таймфреймах с использованием пяти индикаторов для каждой комбинации, для полного охвата требуется 200 хэндлов индикаторов. Как правило, разработчики инициализируют все хэндлы в функции OnInit(). До того как функция OnInit() завершит работу, терминал подключается к каждому источнику ценовых данных, проверяет наличие данных, выделяет буферы для индикаторов и регистрирует хэндлы. Этот процесс приводит к задержке инициализации. Эта задержка увеличивается при холодном запуске терминала или при синхронизации давно не обновлявшихся данных.

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

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

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

  • Инициализация по требованию: хэндлы индикаторов создаются только при первом запросе во время выполнения. Таким образом, затраты на инициализацию возникают не для потенциальных сценариев использования, а только для фактически задействованных.
  • Подсчет ссылок: идентичные конфигурации индикаторов используются совместно. Например, если два отдельных торговых модуля запрашивают средний истинный диапазон (ATR) с одинаковыми параметрами для одного и того же символа и таймфрейма, менеджер создает хэндл один раз и отслеживает число его активных потребителей.
  • Централизованное управление ресурсами: освобождение ресурсов сосредоточено в одном месте. Вместо того чтобы поддерживать длинный список явных вызовов IndicatorRelease() в OnDeinit(), советник вызывает единственный метод очистки из кэша ресурсов.

Алгоритм принятия решений в кэше

Рисунок 1 — Алгоритм принятия решений в кэше. При вызове функции AcquireHandle() кэш проверяет наличие соответствующего ключа. При промахе кэша создается новый хэндл индикатора. При попадании в кэш существующий хэндл возвращается немедленно. Обе ветви алгоритма завершаются возвратом корректного хэндла вызывающей стороне.



Архитектура кэша и проектирование составного ключа

В кэше хранятся записи, индексируемые по составному строковому ключу, который формируется из четырех компонентов: символа, таймфрейма, идентификатора типа индикатора и строкового представления его параметров. Два запроса считаются идентичными только в том случае, если совпадают все четыре компонента. Запрос ATR(14) для символа EURUSD на таймфрейме H1 и запрос ATR(21) для того же символа на том же таймфрейме дают разные ключи и разные хэндлы.

Компоненты составного ключа

Компонент Пример Примечание
Символ EURUSD Имя символа в исходном виде, возвращаемое функцией SymbolName()
Таймфрейм PERIOD_H1 Полная строка, полученная с помощью EnumToString(ENUM_TIMEFRAMES): PERIOD_H1, а не H1
Тип индикатора 0 Целое значение ENUM_INDICATOR_TYPE преобразуется в строку с помощью функции IntegerToString()
параметры 14 Значения параметров, разделенные символом подчеркивания

Ключ для индикатора ATR(14) на графике EURUSD H1 будет EURUSD_PERIOD_H1_0_14, где 0 — целочисленное значение INDT_ATR. Для скользящей средней с периодом 21, смещением 0 и методом EMA, примененной к цене закрытия пары EURUSD на графике H1, ключом будет EURUSD_PERIOD_H1_1_21_0_1_1, где 1 — целое значение INDT_MA. Функция BuildKey() использует IntegerToString((int)ind type), поэтому в ключе хранится целочисленное значение элемента перечисления, а не его метка. Представьте параметры в виде строки с помощью функций IntegerToString() и DoubleToString(), задавая для DoubleToString() фиксированную точность. Не используйте StringFormat(), чтобы избежать зависимости десятичного разделителя от региональных настроек и несогласованности ключей.

Структура записи кэша

Поле Тип Назначение
m_key string Составная строка-идентификатор для поиска
m_handle int Хэндл индикатора в терминале
m_ref_count int Количество активных ссылок на хэндл
m_symbol string Компонент ключа, определяющий символ
m_timeframe ENUM_TIMEFRAMES Компонент ключа, определяющий таймфрейм
m_indicator_type ENUM_INDICATOR_TYPE Значение перечисления типа индикатора
m_params string Строковое представление параметров
m_memory_est_kb double Приблизительный объем занимаемой памяти в КБ

IndicatorEntry.mqh

В файле IndicatorEntry.mqh определены перечисление ENUM_INDICATOR_TYPE и структура SCacheEntry, которая представляет каждую запись в массиве кэша.

//+----------------------------------------------------------------------+
//|                                               IndicatorEntry.mqh     |
//| SCacheEntry: metadata structure per cache entry for CIndicatorCache. |
//| Holds the handle, reference count, composite key, and memory         |
//| estimate for one cached indicator instance.                          |
//|                                                                      |
//| Note: enum member names use the INDT_ prefix to avoid conflict       |
//| with MQL5's built-in ENUM_INDICATOR members (IND_ATR etc.)           |
//+----------------------------------------------------------------------+
#ifndef INDICATORENTRY_MQH
#define INDICATORENTRY_MQH

//+------------------------------------------------------------------+
//| ENUM_INDICATOR_TYPE                                              |
//| Purpose: Identifies the specific indicator subclass type         |
//+------------------------------------------------------------------+
enum ENUM_INDICATOR_TYPE
  {
   INDT_ATR      = 0,  // Average True Range
   INDT_MA       = 1,  // Moving Average
   INDT_RSI      = 2,  // Relative Strength Index
   INDT_MACD     = 3,  // MACD
   INDT_BBANDS   = 4,  // Bollinger Bands
   INDT_STOCH    = 5,  // Stochastic Oscillator
   INDT_CCI      = 6,  // Commodity Channel Index
   INDT_ADX      = 7,  // Average Directional Index
   INDT_ICHIMOKU = 8,  // Ichimoku Kinko Hyo
   INDT_DEMA     = 9   // Double Exponential Moving Average
  };

//+------------------------------------------------------------------+
//| SCacheEntry                                                      |
//| Purpose: Structure holding descriptor data for cache tracking    |
//+------------------------------------------------------------------+
struct SCacheEntry
  {
   string               m_key;              // Composite identity string
   int                  m_handle;           // Terminal indicator handle
   int                  m_ref_count;        // Number of active acquisitions
   string               m_symbol;           // Symbol component
   ENUM_TIMEFRAMES      m_timeframe;        // Timeframe component
   ENUM_INDICATOR_TYPE  m_indicator_type;   // Indicator type component
   string               m_params;           // Serialized parameter string
   double               m_memory_est_kb;    // Approximate memory footprint (KB)

   //--- Default constructor initialization
   SCacheEntry(void)
      :  m_key(""),
         m_handle(INVALID_HANDLE),
         m_ref_count(0),
         m_symbol(""),
         m_timeframe(PERIOD_CURRENT),
         m_indicator_type(INDT_ATR),
         m_params(""),
         m_memory_est_kb(0.0)
     {
      //--- Entry structural setup completed
     }
  };

#endif // INDICATORENTRY_MQH
//+------------------------------------------------------------------+

SCacheEntry хранит всю информацию, необходимую кэшу для управления одним хэндлом индикатора: составной ключ для поиска, целочисленный хэндл терминала, счетчик ссылок и оценку объема памяти для этой записи, используемую модулем сбора статистики. Конструктор по умолчанию инициализирует все поля нулевыми значениями или значениями по умолчанию, поэтому неинициализированная запись всегда находится в безопасном состоянии, которое можно однозначно определить (m_handle == INVALID_HANDLE, m_ref_count == 0).

События жизненного цикла кэша

Событие Внутреннее действие Вывод в журнал
Первый запрос по ключу Создать хэндл; сохранить запись; установить счетчик ссылок равным 1 [CREATE] EURUSD_PERIOD_H1_0_14
Повторный запрос по существующему ключу Увеличить счетчик ссылок; вернуть сохраненный хэндл [HIT] EURUSD_PERIOD_H1_0_14
Запрос на освобождение; счетчик ссылок > 1 Уменьшить счетчик ссылок [RELEASE] EURUSD_PERIOD_H1_0_14 refs=1
Запрос на освобождение; счетчик ссылок достигает 0 Вызвать IndicatorRelease(); удалить запись [DESTROY] EURUSD_PERIOD_H1_0_14
Завершение работы торгового советника (OnDeinit) Освободить все оставшиеся хэндлы независимо от значения счетчика ссылок [FLUSH] кэш очищен, N хэндлов освобождено

CacheStatistics.mqh

В файле CacheStatistics.mqh реализован класс CCacheStatistics, который ведет счетчики попаданий в кэш и промахов кэша и отслеживает оценочное потребление памяти в течение сеанса.

//+------------------------------------------------------------------+
//|                                              CacheStatistics.mqh |
//| CCacheStatistics: tracks hit count, miss count, hit rate, and    |
//| estimated memory usage across the indicator cache session.       |
//+------------------------------------------------------------------+
#ifndef CACHESTATISTICS_MQH
#define CACHESTATISTICS_MQH

//+------------------------------------------------------------------+
//| CCacheStatistics                                                 |
//| Purpose: Performance analytics and telemetry for indicator reuse |
//+------------------------------------------------------------------+
class CCacheStatistics
  {
private:
   long                 m_hit_count;         // Total successful cache reuses
   long                 m_miss_count;        // Total new handle creations
   double               m_memory_est_kb;     // Aggregate estimated memory in KB

public:
                        CCacheStatistics(void);

   void                 RecordHit(void);
   void                 RecordMiss(double entry_memory_kb);
   void                 RecordRelease(double entry_memory_kb);
   void                 Reset(void);

   long                 GetHitCount(void)     const;
   long                 GetMissCount(void)    const;
   double               GetHitRate(void)      const;
   double               GetMemoryEstKB(void)  const;
   long                 GetTotalRequests(void) const;
  };

//+------------------------------------------------------------------+
//| Constructor                                                      |
//+------------------------------------------------------------------+
CCacheStatistics::CCacheStatistics(void)
   :  m_hit_count(0),
      m_miss_count(0),
      m_memory_est_kb(0.0)
  {
   //--- Initialized metric values
  }

//+------------------------------------------------------------------+
//| RecordHit                                                        |
//+------------------------------------------------------------------+
void CCacheStatistics::RecordHit(void)
  {
   m_hit_count++;
  }

//+------------------------------------------------------------------+
//| RecordMiss                                                       |
//+------------------------------------------------------------------+
void CCacheStatistics::RecordMiss(double entry_memory_kb)
  {
   m_miss_count++;
   m_memory_est_kb += entry_memory_kb;
  }

//+------------------------------------------------------------------+
//| RecordRelease                                                    |
//+------------------------------------------------------------------+
void CCacheStatistics::RecordRelease(double entry_memory_kb)
  {
   m_memory_est_kb -= entry_memory_kb;
   if(m_memory_est_kb < 0.0)
     {
      m_memory_est_kb = 0.0;
     }
  }

//+------------------------------------------------------------------+
//| Reset                                                            |
//+------------------------------------------------------------------+
void CCacheStatistics::Reset(void)
  {
   m_hit_count     = 0;
   m_miss_count    = 0;
   m_memory_est_kb = 0.0;
  }

//+------------------------------------------------------------------+
//| GetHitCount                                                      |
//+------------------------------------------------------------------+
long CCacheStatistics::GetHitCount(void) const
  {
   return(m_hit_count);
  }

//+------------------------------------------------------------------+
//| GetMissCount                                                     |
//+------------------------------------------------------------------+
long CCacheStatistics::GetMissCount(void) const
  {
   return(m_miss_count);
  }

//+------------------------------------------------------------------+
//| GetHitRate                                                       |
//+------------------------------------------------------------------+
double CCacheStatistics::GetHitRate(void) const
  {
   long total = m_hit_count + m_miss_count;
   if(total == 0)
     {
      return(0.0);
     }
   return((m_hit_count / (double)total) * 100.0);
  }

//+------------------------------------------------------------------+
//| GetMemoryEstKB                                                   |
//+------------------------------------------------------------------+
double CCacheStatistics::GetMemoryEstKB(void) const
  {
   return(m_memory_est_kb);
  }

//+------------------------------------------------------------------+
//| GetTotalRequests                                                 |
//+------------------------------------------------------------------+
long CCacheStatistics::GetTotalRequests(void) const
  {
   return(m_hit_count + m_miss_count);
  }

#endif // CACHESTATISTICS_MQH
//+------------------------------------------------------------------+

Метод RecordMiss() увеличивает счетчик промахов кэша и прибавляет оценочный объем памяти одной записи к текущему суммарному объему. Метод RecordRelease() вычитает эту величину, ограничивая результат снизу нулём. Это защищает от получения отрицательного значения из-за ошибок вычислений с плавающей запятой, если по какой-либо причине порядок вычитания окажется обратным. Метод GetHitRate() возвращает долю попаданий в кэш в процентах, деля количество попаданий на общее количество запросов. Предварительная проверка на нулевое общее количество запросов предотвращает деление на ноль при первом вызове.

https://c.mql5.com/2/226/Figure_2_9_Bulk_PrecAcquisition_at_Startup.png

Рисунок 2 — Массовое предварительное получение хэндлов при запуске. В ходе выполнения OnInit() кэш создает все 30 необходимых хэндлов индикаторов (5 символов × 2 таймфрейма × 3 индикатора) одним пакетом. При выполнении OnTick() каждый запрос AcquireHandle() находит свой ключ уже в кэше, поэтому в ходе дальнейшей работы вызовы создания хэндлов не требуются.


Перечисление типов индикаторов

Для кэша требуется типизированное перечисление, значения которого сопоставляются со встроенными функциями MQL5 для создания индикаторов. Каждому значению перечисления соответствует своя ветвь создания во внутреннем фабричном методе кэша. В именах элементов используется префикс INDT_, чтобы избежать конфликта имен со встроенным перечислением ENUM_INDICATOR языка MQL5, в котором в глобальном пространстве имён уже определены элементы с идентичными именами, такие как IND_ATR, IND_MA и IND_RSI. Использование тех же имен, что и у встроенного перечисления, приводит к ошибке компиляции независимо от того, к какому перечислению они относятся, поскольку элементы перечислений MQL5 используют единое плоское глобальное пространство идентификаторов.

enum ENUM_INDICATOR_TYPE
{
   INDT_ATR      = 0,  // Average True Range
   INDT_MA       = 1,  // Moving Average
   INDT_RSI      = 2,  // Relative Strength Index
   INDT_MACD     = 3,  // MACD
   INDT_BBANDS   = 4,  // Bollinger Bands
   INDT_STOCH    = 5,  // Stochastic Oscillator
   INDT_CCI      = 6,  // Commodity Channel Index
   INDT_ADX      = 7,  // Average Directional Index
   INDT_ICHIMOKU = 8,  // Ichimoku Kinko Hyo
   INDT_DEMA     = 9   // Double Exponential Moving Average
};

Анализ сложности

Архитектура Стоимость поиска Создание хэндла Использование памяти
Упреждающая инициализация в OnInit() N/A — всё создается заранее O(N) при запуске независимо от фактического использования Пиковое потребление при запуске; далее — постоянное
Последовательное повторное создание на каждом тике O(1) на вызов, но N операций создания за сеанс O(N) суммарно Риск фрагментации, связанный с многократным выделением и освобождением памяти
Кэш с линейным поиском O(N) на каждый поиск Амортизированная сложность O(1) после первого обращения Стабильно; растет только по мере необходимости
Кэш со строковыми ключами (данная реализация) Среднее время O(1) при сравнении строк Амортизированная сложность O(1) после первого обращения Стабильно; ограничено фактическим использованием

На практике среднее время поиска составляет O(1), поскольку линейный просмотр ограничен количеством активных хэндлов, а не теоретическим максимумом. Для советника, работающего с десятью символами, четырьмя таймфреймами и пятью индикаторами, максимально возможное количество записей кэша составляет двести. На практике на каждом тике количество активных записей обычно не превышает двадцати. Число просматриваемых записей остается небольшим, а затраты на сравнение строк фиксированы для заданной длины ключа.


Механизм подсчёта ссылок

Подсчёт ссылок в данном контексте представляет собой простой протокол инкремента и декремента целого числа. Когда потребитель вызывает метод AcquireHandle(), кэш проверяет, существует ли данный ключ. Если ключ существует, счетчик ссылок увеличивается и возвращается хэндл. Если ключ не существует, создается новый хэндл, сохраняется в кэше со счетчиком ссылок, равным 1, и возвращается потребителю. Когда потребитель вызывает метод ReleaseHandle(), счетчик ссылок уменьшается. Когда счетчик ссылок достигает нуля, вызывается функция IndicatorRelease(), и запись удаляется из массива кэша.

Владение ресурсами определено явно: кэш владеет всеми хэндлами, а потребители лишь временно используют их идентификаторы. Они хранят целочисленный хэндл на протяжении всего времени его использования, но не вызывают IndicatorRelease() напрямую. Они вызывают метод ReleaseHandle() кэша, который на основе счетчика ссылок определяет, можно ли освободить соответствующий ресурс. Такая централизация означает, что ни одному отдельному модулю не нужно отслеживать, является ли он последним пользователем данного хэндла.

Жизненный цикл счетчика ссылок

Рисунок 3 — Жизненный цикл счётчика ссылок. Первый вызов AcquireHandle() создает хэндл и устанавливает счетчик ссылок равным 1. Каждый последующий вызов функции AcquireHandle() увеличивает счетчик. Каждый вызов ReleaseHandle() уменьшает счетчик на единицу. Функция IndicatorRelease() выполняется только тогда, когда счетчик достигает нуля, независимо от того, сколько модулей использовали этот хэндл.

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

IndicatorCache.mqh

В файле IndicatorCache.mqh реализован основной класс CIndicatorCache. Особенно важно понять работу трех приведенных ниже методов: BuildKey(), AcquireHandle() и ReleaseHandle().

BuildKey формирует строку составного ключа для поиска:

//+------------------------------------------------------------------+
//| BuildKey                                                         |
//| Purpose: Formulates a unique composite identity reference string |
//+------------------------------------------------------------------+
string CIndicatorCache::BuildKey(string symbol, ENUM_TIMEFRAMES tf,
                                 ENUM_INDICATOR_TYPE ind_type, string params)
  {
   return(symbol + "_" +
          EnumToString(tf) + "_" +
          IntegerToString((int)ind_type) + "_" +
          params);
  }

Функция EnumToString(tf) возвращает строку PERIOD_H1, а не просто целое число, благодаря чему ключи в журнале легко читать. Тип индикатора приводится к int перед вызовом IntegerToString(), а не передается в EnumToString(), поскольку в компоненте ключа, обозначающем тип, для различения используется числовое значение элемента перечисления, а не его имя.

Метод AcquireHandle выполняет поиск в кэше и создает хэндл при промахе кэша:

//+------------------------------------------------------------------+
//| AcquireHandle                                                    |
//| Purpose: Requests handle reference; constructs new if uncached   |
//+------------------------------------------------------------------+
int CIndicatorCache::AcquireHandle(string symbol, ENUM_TIMEFRAMES tf,
                                   ENUM_INDICATOR_TYPE ind_type, string params)
  {
   string key   = BuildKey(symbol, tf, ind_type, params);
   int    index = FindEntry(key);

   if(index >= 0)
     {
      m_entries[index].m_ref_count++;
      m_stats.RecordHit();
      LogMessage("[HIT] " + key + " refs=" + IntegerToString(m_entries[index].m_ref_count));
      return(m_entries[index].m_handle);
     }

   //--- Cache miss validation checks
   if(m_count >= m_max_capacity)
     {
      LogMessage("[CACHE] Capacity limit reached (" + IntegerToString(m_max_capacity) + "). Cannot create: " + key);
      return(INVALID_HANDLE);
     }

   int handle = CreateHandle(symbol, tf, ind_type, params);
   if(handle == INVALID_HANDLE)
     {
      LogMessage("[ERROR] Handle creation failed for: " + key);
      return(INVALID_HANDLE);
     }

   //--- Commit new tracking record
   ArrayResize(m_entries, m_count + 1);
   m_entries[m_count].m_key            = key;
   m_entries[m_count].m_handle         = handle;
   m_entries[m_count].m_ref_count      = 1;
   m_entries[m_count].m_symbol         = symbol;
   m_entries[m_count].m_timeframe      = tf;
   m_entries[m_count].m_indicator_type = ind_type;
   m_entries[m_count].m_params         = params;
   m_entries[m_count].m_memory_est_kb  = CACHE_MEMORY_PER_HANDLE_KB;
   m_count++;

   m_stats.RecordMiss(CACHE_MEMORY_PER_HANDLE_KB);
   LogMessage("[CREATE] " + key);

   return(handle);
  }

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

ReleaseHandle уменьшает счетчик ссылок и при определенном условии уничтожает хэндл:

//+-------------------------------------------------------------------------+
//| ReleaseHandle                                                           |
//| Purpose: Decrements tracking counters, releasing memory if unreferenced |
//+-------------------------------------------------------------------------+
void CIndicatorCache::ReleaseHandle(string symbol, ENUM_TIMEFRAMES tf,
                                    ENUM_INDICATOR_TYPE ind_type, string params)
  {
   string key   = BuildKey(symbol, tf, ind_type, params);
   int    index = FindEntry(key);

   if(index < 0)
     {
      LogMessage("[WARN] ReleaseHandle called for unknown key: " + key);
      return;
     }

   m_entries[index].m_ref_count--;
   LogMessage("[RELEASE] " + key + " refs=" + IntegerToString(m_entries[index].m_ref_count));

   if(m_entries[index].m_ref_count <= 0)
     {
      IndicatorRelease(m_entries[index].m_handle);
      m_stats.RecordRelease(m_entries[index].m_memory_est_kb);
      LogMessage("[DESTROY] " + key);
      RemoveEntry(index);
     }
  }

При освобождении функция IndicatorRelease() вызывается для хэндла, выделенного терминалом, только тогда, когда счетчик ссылок достигает нуля. Затем RemoveEntry() уплотняет массив: перед уменьшением его размера все записи с индексами больше индекса удаленной записи сдвигаются на одну позицию вниз. При вызове ReleaseHandle() для ключа, отсутствующего в кэше, в журнал записывается предупреждение [WARN], после чего функция завершается без сбоя. Это позволяет безопасно диагностировать повторное освобождение хэндла.

Оператор switch в фабричном методе CreateHandle сопоставляет каждому значению перечисления соответствующую функцию MQL5 для создания индикатора. Ниже приведены ветви для ATR и MA, иллюстрирующие устройство полного оператора switch с десятью ветвями:

//+------------------------------------------------------------------+
//| CreateHandle                                                     |
//| Purpose: Instantiates terminal objects using factory mechanics   |
//+------------------------------------------------------------------+
int CIndicatorCache::CreateHandle(string symbol, ENUM_TIMEFRAMES tf,
                                  ENUM_INDICATOR_TYPE ind_type, string params)
  {
   int handle = INVALID_HANDLE;

   switch((int)ind_type)
     {
      case INDT_ATR:
        {
         int period = (int)StringToInteger(params);
         handle = iATR(symbol, tf, period);
         break;
        }
      case INDT_MA:
        {
         string parts[];
         int    part_count = StringSplit(params, '_', parts);
         if(part_count >= 4)
           {
            int period  = (int)StringToInteger(parts[0]);
            int shift   = (int)StringToInteger(parts[1]);
            int method  = (int)StringToInteger(parts[2]);
            int applied = (int)StringToInteger(parts[3]);
            handle = iMA(symbol, tf, period, shift,
                         (ENUM_MA_METHOD)method,
                         (ENUM_APPLIED_PRICE)applied);
           }
         break;
        }
      // ... remaining cases follow the same pattern
      default:
         break;
     }

   return(handle);
  }

Для индикаторов с одним параметром, таких как ATR и ADX, строка params содержит только целое число. Многопараметрические индикаторы, такие как MA и MACD, используют функцию StringSplit() с разделителем в виде подчеркивания для извлечения каждого значения в том порядке, в котором они были сериализованы вспомогательными функциями вызывающего кода.


Предотвращение скрытых утечек

Терминал выделяет для каждого графика ограниченное количество слотов для хэндлов индикаторов. Если торговый советник создает хэндлы в OnInit(), но не освобождает их в OnDeinit(), слоты для хэндлов остаются занятыми. Это может произойти из-за отсутствия кода очистки или остановки, вызванной ошибкой. Например, в течение сеанса, в котором советник многократно перезагружается при оптимизации параметров, накапливаются хэндлы, оставшиеся без владельца. При запуске нового экземпляра советника на том же графике терминал не переиспользует такие хэндлы автоматически.

Метод кэша FlushAll(), безусловно вызываемый в OnDeinit(), перебирает все записи независимо от значений счетчиков ссылок и вызывает IndicatorRelease() для каждого хэндла. Этот единственный вызов гарантирует полную очистку кэша независимо от того, вызывали ли отдельные модули ReleaseHandle() до остановки советника. Это структурный аналог деструктора, который невозможно обойти.

Метод FlushAll() записывает в журнал сведения о каждом освобожденном хэндле с префиксом [FLUSH], позволяя после завершения сеанса проверить, что ни один хэндл не остался неосвобожденным без уведомления.

Программа LazyLoadingEA.mq5 демонстрирует работу кэша. Функция OnInit() выполняет предварительное получение всех хэндлов, OnTick() считывает данные, напрямую используя сохраненные целочисленные значения хэндлов, а OnDeinit() безусловно выполняет полную очистку кэша.

OnInit — цикл предварительного получения хэндлов:

for(int s = 0; s < g_symbol_count; s++)
     {
      string sym = g_symbols[s];

      for(int t = 0; t < g_tf_count; t++)
        {
         ENUM_TIMEFRAMES tf = g_timeframes[t];

         string ma_params  = BuildMAParams(inp_ma_period, 0, (int)MODE_EMA, (int)PRICE_CLOSE);
         string rsi_params = BuildRSIParams(inp_rsi_period, (int)PRICE_CLOSE);

         //--- Populate collection array by executing initial cache lazy-allocations
         g_handles[g_handle_count]     = g_cache.AcquireHandle(sym, tf, INDT_ATR, IntegerToString(inp_atr_period));
         g_handles[g_handle_count + 1] = g_cache.AcquireHandle(sym, tf, INDT_MA,  ma_params);
         g_handles[g_handle_count + 2] = g_cache.AcquireHandle(sym, tf, INDT_RSI, rsi_params);
         g_handle_count += 3;
        }
     }

Каждый вызов AcquireHandle() — первый запрос для соответствующего ключа, поэтому каждый вызов приводит к промаху кэша, созданию хэндла и выводу сообщения в журнал. Возвращаемые целочисленные значения хэндлов сохраняются в массиве g_handles[] для непосредственного использования в OnTick().

OnTick — прямое чтение из буферов без взаимодействия с кэшем:

double buf[1];
   for(int i = 0; i < g_handle_count; i++)
     {
      if(g_handles[i] != INVALID_HANDLE)
        {
         CopyBuffer(g_handles[i], 0, 0, 1, buf);
        }
     }

   //--- Update dashboard with current cache statistics
   g_dashboard.Update(g_cache.GetActiveCount(), g_cache.GetStatistics());

Функция OnTick() напрямую вызывает CopyBuffer(), передавая сохраненные целочисленные значения хэндлов. Функция OnTick() не вызывает AcquireHandle() повторно, поэтому в этой модели с одним потребителем счетчик попаданий в кэш остается равным нулю. Это соответствует поведению информационной панели, описанному в разделе "Проектирование информационной панели".

OnDeinit — безусловная очистка:

if(CheckPointer(g_cache) == POINTER_DYNAMIC)
     {
      g_cache.FlushAll();
      delete g_cache;
      g_cache = NULL;
     }

   ArrayFree(g_handles);
   g_handle_count = 0;

   if(CheckPointer(g_dashboard) == POINTER_DYNAMIC)
     {
      g_dashboard.Remove();
      delete g_dashboard;
      g_dashboard = NULL;
     }

Функция FlushAll() освобождает все хэндлы в кэше независимо от значений их счетчиков ссылок, прежде чем объект кэша будет удален оператором delete. Функция CheckPointer() предотвращает повторное освобождение объекта, если функция OnDeinit() по какой-либо причине вызывается после предыдущей частичной очистки. Вызов метода Remove() информационной панели удаляет все объекты графика до освобождения указателя.


Проектирование информационной панели

Информационная панель отображает четыре показателя в виде текстовых меток в левом верхнем углу графика торгового советника. Панель обновляется при каждом вызове метода Update(), который вызывается из OnTick(). Отображаются четыре значения: количество активных хэндлов, доля попаданий в кэш в процентах, общее количество промахов кэша и оценочный объем используемой памяти в КБ.

Для оценки объема памяти используется фиксированное базовое значение 48 КБ на один хэндл для стандартных индикаторов на графике из 500 баров, умноженное на количество активных хэндлов. Это приблизительные данные; фактический объем памяти, выделяемый терминалом для буферов индикаторов, зависит от типа индикатора, количества баров и количества буферов. Этот показатель служит скорее относительным индикатором потребления ресурсов, чем точным измерением.

В модели предварительного получения хэндлов с одним потребителем, используемой в демонстрационном торговом советнике, доля попаданий в кэш, отображаемая на информационной панели, остается на уровне 0,0 % на протяжении всего сеанса. Это корректно: все 30 хэндлов получаются один раз в OnInit() при промахах кэша, а OnTick() читает данные напрямую с помощью сохраненных целочисленных хэндлов, не вызывая AcquireHandle() повторно, поэтому счетчик попаданий никогда не увеличивается. Показатель доли попаданий в кэш наиболее значим в архитектуре с несколькими потребителями, где отдельные торговые модули независимо вызывают AcquireHandle() для одного и того же индикатора на одном и том же символе и таймфрейме, что приводит к попаданиям в кэш после первоначального получения хэндла первым модулем. Значения на информационной панели, представленные на рисунке 4 ниже, иллюстрируют этот сценарий с несколькими потребителями.

В файле DashboardPanel.mqh реализован класс CDashboardPanel, который создает и обновляет пять графических объектов-меток на графике советника.

//+------------------------------------------------------------------+
//| Update                                                           |
//| Purpose: Pushes refreshed metrics onto the target chart context  |
//+------------------------------------------------------------------+
void CDashboardPanel::Update(int active_handles, CCacheStatistics *stats)
  {
   //--- Construct layout components dynamically on initial runtime loop
   if(!m_initialized)
     {
      CreateLabel(m_prefix + "TITLE",   m_y_offset);
      CreateLabel(m_prefix + "HANDLES", m_y_offset + m_line_spacing);
      CreateLabel(m_prefix + "HITRATE", m_y_offset + m_line_spacing * 2);
      CreateLabel(m_prefix + "MISSES",  m_y_offset + m_line_spacing * 3);
      CreateLabel(m_prefix + "MEMORY",  m_y_offset + m_line_spacing * 4);
      m_initialized = true;
     }

   //--- Bind raw statistics inputs to display formats
   SetLabelText(m_prefix + "TITLE",   "[ Indicator Cache ]");
   SetLabelText(m_prefix + "HANDLES", "Active Handles : " + IntegerToString(active_handles));
   SetLabelText(m_prefix + "HITRATE", "Cache Hit Rate : " + DoubleToString(stats.GetHitRate(), 1) + "%");
   SetLabelText(m_prefix + "MISSES",  "Cache Misses   : " + IntegerToString(stats.GetMissCount()));
   SetLabelText(m_prefix + "MEMORY",  "Est. Memory    : " + DoubleToString(stats.GetMemoryEstKB(), 0) + " KB");

   ChartRedraw(m_chart_id);
  }

Объекты меток создаются только при первом вызове, что контролируется флагом m_initialized. Каждый последующий вызов обновляет текст существующих объектов, а не создаёт их заново, что позволяет избежать накладных расходов, связанных с вызовом ObjectCreate(), при каждом тике. Метод CreateLabel() использует ObjectFind(), чтобы пропустить создание объекта, если объект с таким именем уже существует, благодаря чему метод Update() можно безопасно вызывать, даже если информационная панель не была корректно удалена между перезагрузками советника.

//+--------------------------------------------------------------------+
//| Remove                                                             |
//| Purpose: Purges graphic resources sequentially from terminal space |
//+--------------------------------------------------------------------+
void CDashboardPanel::Remove(void)
  {
   string labels[] = {"TITLE", "HANDLES", "HITRATE", "MISSES", "MEMORY"};
   int size = ArraySize(labels);

   for(int i = 0; i < size; i++)
     {
      ObjectDelete(m_chart_id, m_prefix + labels[i]);
     }

   ChartRedraw(m_chart_id);
   m_initialized = false;
  }

Метод Remove() проходит по фиксированному списку имен меток и удаляет каждый объект. Установка m_initialized в false означает, что последующий вызов Update() после Remove() корректно создаст метки заново; это полезно при изменении параметров советника, вызывающем цикл деинициализации и повторной инициализации без полного перезапуска терминала.

Информационная панель на графике для отслеживания производительности кэша

Рисунок 4 — Информационная панель на графике для отслеживания производительности кэша


Ограничения и структурные особенности

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

Кэш не расширяется динамически: массив кэша имеет фиксированную максимальную емкость, заданную параметром inp_max_cache_size. Попытки добавить записи сверх этого предела отклоняются и регистрируются в журнале, а вызывающая сторона получает значение INVALID_HANDLE. Для советника с четко определенным набором задач емкость кэша следует задавать как произведение количества символов, количества таймфреймов и количества типов индикаторов с запасом 20%.

Только однопоточное выполнение: каждый экземпляр советника или индикатора MQL5 выполняется в одном потоке. В реализации кэша не используются мьютексы или атомарные операции. Если компания MetaQuotes внедрит контексты параллельного выполнения в будущих сборках, в методах AcquireHandle() и ReleaseHandle() потребуется защитить операции изменения счетчика ссылок и доступа к массиву средствами синхронизации.

Отсутствие повторной проверки действительности хэндлов: кэш не проверяет периодически, остаются ли сохраненные хэндлы действительными. Если терминал в ходе внутренних операций делает хэндл недействительным при смене таймфрейма графика или удалении символа из окна "Обзор рынка", кэш продолжит возвращать устаревшее целочисленное значение, пока потребитель не сообщит об ошибке копирования данных. Периодическая проверка действительности хэндлов потребовала бы дополнительных затрат времени O(N) за каждый цикл проверки.


Заключение

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

Архитектура основана на трёх основных технических компонентах:

  • Независимое совместное использование хэндлов: модель с подсчетом ссылок управляет совместно используемыми индикаторами без необходимости координации между модулями. Если два модуля запрашивают одинаковые конфигурации индикаторов, кэш возвращает один и тот же хэндл. Каждый модуль отдельно освобождает свою ссылку на него. Соответствующий ресурс терминала создается один раз и освобождается один раз.
  • Автоматическая очистка ресурсов: метод FlushAll() гарантирует полное освобождение ресурсов. Если торговый советник останавливается из-за ошибки, действия пользователя или перезапуска платформы, кэш освобождает все хэндлы терминала. Централизованное владение ресурсами устраняет необходимость в отдельном коде очистки в каждом модуле, снижая риск незаметных утечек хэндлов по мере расширения кодовой базы.
  • Измеримые накладные расходы: данная архитектура влечет за собой предсказуемые компромиссы в плане производительности. Они включают сравнение строк при каждом поиске, две целочисленные операции за каждый цикл получения/освобождения и небольшой фиксированный объем памяти на каждую запись кэша.

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


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

# Имя Тип Описание
1 IndicatorEntry.mqh Подключаемый файл Структура SCacheEntry и перечисление ENUM_INDICATOR_TYPE с элементами с префиксом INDT_; структура хранит хэндл, счетчик ссылок, составной ключ, тип, параметры и оценку объема памяти для каждого кэшированного индикатора
2 CacheStatistics.mqh Подключаемый файл
Класс CCacheStatistics, отслеживающий количество попаданий в кэш, количество промахов кэша, долю попаданий в кэш и оценочный объем используемой памяти в течение сеанса
3 IndicatorCache.mqh Подключаемый файл
Класс CIndicatorCache, реализующий формирование составных ключей, отложенное создание хэндлов с помощью фабричного метода на основе оператора switch с десятью ветвями, получение и освобождение с подсчетом ссылок, полную очистку кэша с помощью FlushAll() и делегирование сбора статистики
4 DashboardPanel.mqh Подключаемый файл
Класс CDashboardPanel, отображающий сведения об активных хэндлах, долю попаданий в кэш, количество промахов кэша и оценку объема памяти в виде обновляемых объектов-меток на графике с использованием шрифта Consolas
5 LazyLoadingEA.mq5 Демонстрационный советник Демонстрационный торговый советник, выполняющий предварительное получение хэндлов ATR, MA и RSI для всех заданных символов и таймфреймов в OnInit(), чтение данных с использованием сохраненных целочисленных хэндлов в OnTick() без обращения к кэшу, запись событий создания в журнал, обновление информационной панели и детерминированную очистку с помощью FlushAll() в OnDeinit()
6 Lazy_Loading_Indicator_Handles_in_MQL5.zip Архив в формате ZIP ZIP-архив, содержащий все прикрепленные файлы и их пути относительно корневой папки терминала.

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

Прикрепленные файлы |
IndicatorEntry.mqh (2.86 KB)
IndicatorCache.mqh (14.13 KB)
DashboardPanel.mqh (5.72 KB)
LazyLoadingEA.mq5 (8.02 KB)
Последние комментарии | Перейти к обсуждению на форуме трейдеров (1)
Stanislav Korotky
Stanislav Korotky | 29 июн. 2026 в 15:50
Всё это вам не понадобится: платформа изначально и автоматически поддерживает подсчёт ссылок и очистку ресурсов (для дескрипторов индикаторов, оставшихся без ссылок после вызова `deinit`).
Архитектура машинного обучения в MetaTrader 5 (Часть 18): Исправленный последовательный бутстрэп — клонирование, стирание типа и инструментарий сравнения Архитектура машинного обучения в MetaTrader 5 (Часть 18): Исправленный последовательный бутстрэп — клонирование, стирание типа и инструментарий сравнения
В статье выявляются два дефекта, которые сводят на нет эффект последовательного бутстрэпа при перекрестной проверке: стирание типа SequentiallyBootstrappedBaggingClassifier и несоответствие размерностей на уровне фолда из-за клонирования полного набора информационных интервалов наблюдений. Предлагаемое исправление сохраняет принадлежность классификатора к исходному классу, добавляет функцию _find_seq_bagging() для повторной передачи в CalibratorCV.fit данных t1, ограниченных текущим фолдом, и сбрасывает состояние при каждом разбиении. Новый модуль bootstrap_comparison выводит OOF-метрики и на наблюдениях вне бутстрэп-выборки (OOB), а также данные об использовании памяти, позволяя проверить правильность применения последовательного отбора и количественно оценить его влияние.
Проектирование конечного автомата торговой стратегии на MQL5: Замена вложенных конструкций if-else явными состояниями Проектирование конечного автомата торговой стратегии на MQL5: Замена вложенных конструкций if-else явными состояниями
Вложенные условные конструкции if-else внутри функции OnTick() создают неявные состояния, которые сложно изолировать, отлаживать и расширять без возникновения регрессионных ошибок. Реализация конечного автомата (FSM) на MQL5 использует интерфейс IState, посредник CStrategyContext и четыре конкретных состояния, чтобы отделить логику обнаружения от логики поведения. Структура из трёх подключаемых файлов устраняет циклические зависимости подключаемых файлов и чётко разделяет объявления, определения и создание экземпляров, делая внесение изменений безопаснее, а отладку — быстрее.
Возможности Мастера MQL5, которые вам нужно знать (Часть 99): Использование KD-дерева и эхо-сети (ESN) в пользовательском классе управления капиталом Возможности Мастера MQL5, которые вам нужно знать (Часть 99): Использование KD-дерева и эхо-сети (ESN) в пользовательском классе управления капиталом
В этой статье описывается пользовательский класс управления капиталом CMoneyKDTreeESN, совместимый с Мастером MQL5 и объединяющий алгоритм KD-дерева и эхо-сеть (ESN). Мы применяем KD-дерево к логарифмическим доходностям и ATR (среднему истинному диапазону), чтобы получить оценку риска, а ESN отслеживает недавнюю рыночную динамику и выдает множитель объема позиции с ограниченным диапазоном значений. Наш класс можно использовать в различных советниках, созданных с помощью Мастера MQL5, как показано здесь на примере сигналов Envelopes и RSI; общая цель — регулировать рисковую экспозицию в условиях высокой волатильности и хвостового риска.
Фильтрация Вьеториса — Рипса в MQL5: Построение симплексов и граничной матрицы Фильтрация Вьеториса — Рипса в MQL5: Построение симплексов и граничной матрицы
Мы преобразуем облако точек, построенное с помощью вложения ценового ряда, в фильтрацию Вьеториса — Рипса и соответствующую ей граничную матрицу. В статье вершины, ребра и треугольники перечисляются с указанием значений фильтрации и сортируются в порядке добавления в фильтрацию; также создаются таблицы поиска вершин и ребер за время O(1). Вы получаете классы CTDARips и CTDABoundary на MQL5, а также разреженную граничную матрицу над Z/2, пригодную для последующей редукции при вычислении персистентности.