Отложенная загрузка хендлов индикаторов в MQL5: Менеджер ресурсов для мультитаймфреймовых советников
Введение
Мультитаймфреймовому торговому советнику (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() возвращает долю попаданий в кэш в процентах, деля количество попаданий на общее количество запросов. Предварительная проверка на нулевое общее количество запросов предотвращает деление на ноль при первом вызове.

Рисунок 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
Предупреждение: все права на данные материалы принадлежат MetaQuotes Ltd. Полная или частичная перепечатка запрещена.
Данная статья написана пользователем сайта и отражает его личную точку зрения. Компания MetaQuotes Ltd не несет ответственности за достоверность представленной информации, а также за возможные последствия использования описанных решений, стратегий или рекомендаций.
Архитектура машинного обучения в MetaTrader 5 (Часть 18): Исправленный последовательный бутстрэп — клонирование, стирание типа и инструментарий сравнения
Проектирование конечного автомата торговой стратегии на MQL5: Замена вложенных конструкций if-else явными состояниями
Возможности Мастера MQL5, которые вам нужно знать (Часть 99): Использование KD-дерева и эхо-сети (ESN) в пользовательском классе управления капиталом
Фильтрация Вьеториса — Рипса в MQL5: Построение симплексов и граничной матрицы
- Бесплатные приложения для трейдинга
- 8 000+ сигналов для копирования
- Экономические новости для анализа финансовых рынков
Вы принимаете политику сайта и условия использования