English
preview
Кэширование метаданных символов и расписаний торговых сессий в MQL5: Устранение лишних вызовов SymbolInfo в мультисимвольных советниках

Кэширование метаданных символов и расписаний торговых сессий в MQL5: Устранение лишних вызовов SymbolInfo в мультисимвольных советниках

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

Введение

Мультисимвольный советник, отслеживающий двадцать инструментов, обычно вызывает SymbolInfoDouble(), SymbolInfoInteger() и связанные функции на каждом тике для каждого отслеживаемого символа. Значение пункта, стоимость тика, шаг объема и количество знаков после запятой — минимальный набор данных, необходимый для расчета размера позиции и форматирования цен. При скорости 10 тиков в секунду для 20 символов и четырех таких вызовах на символ за тик получается 800 обращений к терминалу в секунду.

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

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

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

В результате создается класс CSymbolMetaCache с типизированными геттерами для каждого кэшированного поля, методом IsMarketOpen(), который определяет, открыта ли торговая сессия, только по данным в памяти, и методом Refresh() — единственным способом повторно обратиться к API терминала после инициализации. Скрипт теста производительности измеряет фактическое изменение задержки для набора из двадцати символов, позволяя оценить практический эффект кэширования.

Symbol metadata cache — before and after

Рисунок 1. Наглядная схема кэширования. Без кэша советник на каждом тике выполняет сотни запросов свойств SymbolInfo к терминалу — эти накладные расходы растут с каждым добавленным символом. При использовании CSymbolMetaCache советник считывает данные из памяти при каждом тике. К API терминала он обращается только при инициализации через Init(), а затем — не чаще одного раза в день через Refresh().


Раздел 1 — скрытые затраты на повторные вызовы SymbolInfo

Рассмотрим советник, работающий с двадцатью символами и вызывающий по четыре функции SymbolInfo для каждого символа при каждом тике. При частоте 10 тиков в секунду это составляет 20 × 4 × 10 = 800 вызовов в секунду. Если внутренний поиск при каждом вызове занимает примерно 0,5 микросекунды, суммарные накладные расходы составляют 400 микросекунд в секунду. Само по себе это несущественно.

При увеличении количества символов и частоты тиков картина меняется. Советник, работающий с 50 символами и запрашивающий по 10 свойств каждого при частоте 50 тиков в секунду, выполняет 50 × 10 × 50 = 25 000 вызовов в секунду. При той же оценке накладные расходы составляют 12,5 миллисекунды в секунду. При таких масштабах накладные расходы начинают напрямую конкурировать с расчетом торговых сигналов советника.

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


Раздел 2 — что следует кэшировать, а что нет

Свойства символов делятся на две категории, к которым применяются принципиально разные подходы к кэшированию.

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

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

К свойствам, которые можно безопасно кэшировать, относятся размер пункта (SYMBOL_POINT), размер тика (SYMBOL_TRADE_TICK_SIZE), шаг объема (SYMBOL_VOLUME_STEP), минимальный и максимальный объем (SYMBOL_VOLUME_MIN, SYMBOL_VOLUME_MAX), размер контракта (SYMBOL_TRADE_CONTRACT_SIZE), количество знаков после запятой (SYMBOL_DIGITS), начальная маржа (SYMBOL_MARGIN_INITIAL), три валютных поля (SYMBOL_CURRENCY_BASE, SYMBOL_CURRENCY_PROFIT, SYMBOL_CURRENCY_MARGIN), режим торговли (SYMBOL_TRADE_MODE) и расписание сессий, возвращаемое функцией SymbolInfoSessionTrade().

К свойствам, которые нельзя кэшировать, относятся Bid (SYMBOL_BID), Ask (SYMBOL_ASK), последняя цена (SYMBOL_LAST), текущий спред (SYMBOL_SPREAD), тиковый объем (SYMBOL_VOLUME), а также максимум и минимум цены за сессию.

Одно свойство требует отдельного пояснения: SYMBOL_TRADE_TICK_VALUE. Для символов, у которых валюта прибыли совпадает с валютой счета, например EURUSD на счете в USD, стоимость тика действительно постоянна и ее можно безопасно кэшировать. Для кросс-валютных пар, в которых валюта прибыли отличается от валюты счета, терминал постоянно пересчитывает стоимость тика по мере изменения обменных курсов. Для таких символов стоимость тика на практике полудинамическая. При длительном использовании кэша для мультивалютного набора символов небольшие отклонения от кэшированной стоимости тика следует считать ожидаемыми, а не ошибочными. Проверка корректности в скрипте теста производительности учитывает это: стоимость тика сравнивается с процентным допуском, а не с фиксированным абсолютным.


Раздел 3 — расписание торговых сессий в MQL5

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

Чтобы получить расписание на всю неделю, нужен вложенный цикл. Внешний цикл проходит по семи значениям ENUM_DAY_OF_WEEK — от SUNDAY до SATURDAY. Для каждого дня внутренний цикл начинает с индекса сессии 0 и увеличивает его, пока SymbolInfoSessionTrade() не вернет false, сигнализируя, что сессий на этот день больше нет. Каждая найденная сессия добавляется в массив SSessionWindow; для этого ArrayResize() увеличивает его размер на один элемент.

Чтобы вычислить IsMarketOpen() для заданного значения datetime, из него извлекается день недели, время переводится в секунды от полуночи, затем проверяется, попадает ли оно в один из кэшированных интервалов сессий этого дня. Это чисто арифметическое сравнение, не требующее повторных обращений к API терминала после загрузки расписания.


Раздел 4 — стратегия инвалидации и обновления кэша

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

В этой реализации предусмотрен явный вызов метода Refresh(), а автоматического определения устаревших данных по флагу нет. Причина проста: для автоматического обнаружения пришлось бы регулярно сравнивать текущее значение SymbolInfo с кэшированным. Это вновь привело бы к тем вызовам API терминала, ради устранения которых создается кэш.

В однопоточной модели выполнения MQL5, где OnTick(), OnTimer() и другие обработчики событий не выполняются одновременно, достаточно запускать обновление кэша вручную в заведомо безопасный момент. Демонстрационный советник использует EventSetTimer() для ежечасного вызова обработчика и вызывает Refresh() только при наступлении часа 00 по серверному времени. Так задается предсказуемый момент ежедневного обновления кэша.


Раздел 5 — реализация SymbolMeta.mqh

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

//+------------------------------------------------------------------+
//|                                                   SymbolMeta.mqh |
//+------------------------------------------------------------------+
#ifndef SYMBOLMETA_MQH
#define SYMBOLMETA_MQH

//+------------------------------------------------------------------+
//| One contiguous trading session window within a single day        |
//+------------------------------------------------------------------+
struct SSessionWindow
  {
   ENUM_DAY_OF_WEEK  day_of_week;
   int               start_seconds;
   int               end_seconds;
  };

//+------------------------------------------------------------------+
//| Cached contract specification and session schedule for a symbol  |
//+------------------------------------------------------------------+
struct SSymbolMeta
  {
   string                  symbol;
   int                     digits;
   double                  point;
   double                  tick_size;
   double                  tick_value;
   double                  lot_step;
   double                  lot_min;
   double                  lot_max;
   double                  contract_size;
   double                  margin_initial;
   string                  currency_base;
   string                  currency_profit;
   string                  currency_margin;
   ENUM_SYMBOL_TRADE_MODE  trade_mode;
   SSessionWindow          sessions[];
   bool                    is_valid;

   void                    Print(void) const;
  };

//+------------------------------------------------------------------+
//| Write all fields of this record to the Experts tab               |
//+------------------------------------------------------------------+
void SSymbolMeta::Print(void) const
  {
   ::Print("--- SSymbolMeta: ", symbol, " ---");
   ::PrintFormat("  digits=%d  point=%.5f  tick_size=%.5f  tick_value=%.5f",
                 digits, point, tick_size, tick_value);
   ::PrintFormat("  lot_step=%.2f  lot_min=%.2f  lot_max=%.2f  contract_size=%.2f",
                 lot_step, lot_min, lot_max, contract_size);
   ::PrintFormat("  margin_initial=%.2f  currencies=%s/%s/%s  trade_mode=%d",
                 margin_initial, currency_base, currency_profit,
                 currency_margin, (int)trade_mode);
   ::PrintFormat("  sessions=%d  is_valid=%s",
                 ::ArraySize(sessions), (is_valid ? "true" : "false"));

   for(int i = 0; i < ::ArraySize(sessions); i++)
     {
      ::PrintFormat("    day=%d  start=%ds  end=%ds",
                    (int)sessions[i].day_of_week,
                    sessions[i].start_seconds,
                    sessions[i].end_seconds);
     }
  }

#endif // SYMBOLMETA_MQH
//+------------------------------------------------------------------+

SSessionWindow содержит три поля. day_of_week указывает день недели, к которому относится интервал. start_seconds и end_seconds задают его границы в секундах от полуночи — в том же формате, в котором их возвращает SymbolInfoSessionTrade().

SSymbolMeta содержит полный кэшированный снимок данных одного символа. Числовые поля спецификации контракта охватывают все статические свойства, перечисленные в разделе 2. Три строковых поля задают базовую валюту, валюту прибыли и валюту маржи. trade_mode указывает, разрешена ли торговля символом в полном объеме или с ограничениями. sessions[] — динамический массив интервалов торговых сессий, заполняемый вложенным циклом получения данных, описанным в разделе 3. Поле is_valid принимает значение true только после успешного получения данных, позволяя вызывающему коду отличить корректно заполненную запись от созданной по умолчанию или не заполненной из-за ошибки.

Метод Print() выводит все поля на вкладку "Эксперты" в структурированном формате. Метод вызывается демонстрационным советником сразу после инициализации, чтобы визуально проверить корректность полученных данных.


Раздел 6 — реализация SymbolMetaCache.mqh

Класс CSymbolMetaCache управляет кэшем. При инициализации класс ровно один раз получает метаданные каждого отслеживаемого символа и сохраняет их в памяти. Типизированные геттеры читают данные из кэша, не обращаясь к API терминала; после Init() повторные обращения к API возможны только через Refresh() и RefreshSymbol().

//+------------------------------------------------------------------+
//|                                              SymbolMetaCache.mqh |
//+------------------------------------------------------------------+
#ifndef SYMBOLMETACACHE_MQH
#define SYMBOLMETACACHE_MQH

#include "SymbolMeta.mqh"

//+------------------------------------------------------------------+
//| Pre-fetches and serves symbol contract specs and session windows |
//+------------------------------------------------------------------+
class CSymbolMetaCache
  {
private:
   string            m_symbols[];
   SSymbolMeta       m_meta[];
   int               m_n;
   datetime          m_last_refresh;

   int               FindIndex(const string &symbol) const;
   bool              FetchMeta(int idx);

public:
                     CSymbolMetaCache(void);
                    ~CSymbolMetaCache(void);

   bool              Init(const string &symbols[], int n);
   void              Refresh(void);
   bool              RefreshSymbol(const string &symbol);

   double            GetPoint(const string &symbol) const;
   int               GetDigits(const string &symbol) const;
   double            GetTickValue(const string &symbol) const;
   double            GetLotStep(const string &symbol) const;
   double            GetContractSize(const string &symbol) const;
   ENUM_SYMBOL_TRADE_MODE GetTradeMode(const string &symbol) const;
   bool              IsMarketOpen(const string &symbol, datetime dt) const;
   bool              GetMeta(const string &symbol, SSymbolMeta &out_meta) const;

   datetime          GetLastRefreshTime(void) const;
   void              PrintAll(void) const;
  };

m_symbols[] и m_meta[] — параллельные массивы: в m_symbols[i] хранится имя символа, а в m_meta[i] — его кэшированные данные. Размеры и содержимое обоих массивов всегда обновляются согласованно. m_n хранит количество символов. m_last_refresh хранит время последнего обновления кэша; получить его для диагностики можно через GetLastRefreshTime().

Методы FindIndex() и FetchMeta() являются закрытыми. Метод FindIndex() возвращает индекс символа в массиве по его имени. Метод FetchMeta() выполняет непосредственные вызовы API терминала для символа с заданным индексом и используется как в Init(), так и в методах обновления кэша. Каждый публичный геттер сначала вызывает FindIndex().

//+------------------------------------------------------------------+
//| Locate the array index of a symbol by name, or -1 if not found   |
//+------------------------------------------------------------------+
int CSymbolMetaCache::FindIndex(const string &symbol) const
  {
   for(int i = 0; i < m_n; i++)
     {
      if(m_symbols[i] == symbol)
         return(i);
     }

   return(-1);
  }

Линейный просмотр массива m_symbols[]. Для небольшого числа символов, на которое рассчитан этот кэш, линейного поиска достаточно. Каждый публичный геттер использует этот метод и явно обрабатывает случай, когда символ не найден и возвращается -1: вместо ошибки выхода за границы массива он возвращает безопасное значение по умолчанию.

FetchMeta()

//+------------------------------------------------------------------+
//| Fetch and populate metadata for one symbol by array index        |
//+------------------------------------------------------------------+
bool CSymbolMetaCache::FetchMeta(int idx)
  {
   string symbol = m_symbols[idx];

   if(!::SymbolSelect(symbol, true))
     {
      ::Print("CSymbolMetaCache::FetchMeta - SymbolSelect failed for ", symbol);
      m_meta[idx].is_valid = false;
      return(false);
     }

   m_meta[idx].symbol         = symbol;
   m_meta[idx].digits         = (int)::SymbolInfoInteger(symbol, SYMBOL_DIGITS);
   m_meta[idx].point          = ::SymbolInfoDouble(symbol, SYMBOL_POINT);
   m_meta[idx].tick_size      = ::SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_SIZE);
   m_meta[idx].tick_value     = ::SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_VALUE);
   m_meta[idx].lot_step       = ::SymbolInfoDouble(symbol, SYMBOL_VOLUME_STEP);
   m_meta[idx].lot_min        = ::SymbolInfoDouble(symbol, SYMBOL_VOLUME_MIN);
   m_meta[idx].lot_max        = ::SymbolInfoDouble(symbol, SYMBOL_VOLUME_MAX);
   m_meta[idx].contract_size  = ::SymbolInfoDouble(symbol, SYMBOL_TRADE_CONTRACT_SIZE);
   m_meta[idx].margin_initial = ::SymbolInfoDouble(symbol, SYMBOL_MARGIN_INITIAL);
   m_meta[idx].currency_base   = ::SymbolInfoString(symbol, SYMBOL_CURRENCY_BASE);
   m_meta[idx].currency_profit = ::SymbolInfoString(symbol, SYMBOL_CURRENCY_PROFIT);
   m_meta[idx].currency_margin = ::SymbolInfoString(symbol, SYMBOL_CURRENCY_MARGIN);
   m_meta[idx].trade_mode      = (ENUM_SYMBOL_TRADE_MODE)::SymbolInfoInteger(symbol, SYMBOL_TRADE_MODE);

//--- fetch session schedule via nested loop
   ArrayResize(m_meta[idx].sessions, 0);

   for(int day = 0; day <= 6; day++)
     {
      ENUM_DAY_OF_WEEK dow = (ENUM_DAY_OF_WEEK)day;
      int session_idx        = 0;

      while(true)
        {
         datetime start_dt, end_dt;

         if(!::SymbolInfoSessionTrade(symbol, dow, session_idx, start_dt, end_dt))
            break;

         int slot = ::ArraySize(m_meta[idx].sessions);
         ArrayResize(m_meta[idx].sessions, slot + 1);
         m_meta[idx].sessions[slot].day_of_week    = dow;
         m_meta[idx].sessions[slot].start_seconds  = (int)start_dt;
         m_meta[idx].sessions[slot].end_seconds    = (int)end_dt;

         session_idx++;
        }
     }

   m_meta[idx].is_valid = true;
   return(true);
  }

Метод FetchMeta() выполняет все вызовы API терминала, которые этому классу когда-либо понадобятся для данного символа. Сначала функция SymbolSelect() вызывается со значением true, чтобы сделать символ активным в Обзоре рынка и обеспечить получение актуальных данных функциями SymbolInfo*(). Если выбрать символ не удалось, запись помечается как недействительная, а метод возвращает false.

При успешном выполнении каждое статическое поле считывается один раз соответствующей типизированной функцией. Функция SymbolInfoInteger() получает свойства целочисленного типа. Функция SymbolInfoDouble() получает числовые свойства спецификации контракта. Функция SymbolInfoString() получает строковые свойства с названиями валют.

Расписание сессий получают по схеме с вложенными циклами, описанной в разделе 3. Внешний цикл перебирает все семь дней недели. Для каждого дня внутренний цикл while(true) вызывает функцию SymbolInfoSessionTrade() с возрастающими индексами сессий и прекращает работу, когда вызов возвращает false. Каждый найденный интервал торговой сессии добавляется в массив sessions, размер которого при этом увеличивается на один элемент. После заполнения всех полей is_valid устанавливается в true.

Init()

//+------------------------------------------------------------------+
//| Allocate cache storage and fetch metadata for all symbols        |
//+------------------------------------------------------------------+
bool CSymbolMetaCache::Init(const string &symbols[], int n)
  {
   m_n = n;
   ArrayResize(m_symbols, n);
   ArrayResize(m_meta, n);

   bool all_ok = true;

   for(int i = 0; i < n; i++)
     {
      m_symbols[i] = symbols[i];
      if(!FetchMeta(i))
         all_ok = false;
     }

   m_last_refresh = TimeTradeServer();
   return(all_ok);
  }

Init() — точка входа, вызываемая из OnInit() для однократной инициализации. Метод задает размеры обоих параллельных массивов, копирует имена символов в m_symbols[] и сразу вызывает FetchMeta() для каждого соответствующего индекса. Если получить данные хотя бы для одного символа не удалось, флаг all_ok получает значение false. После обработки всех символов в m_last_refresh записывается текущее серверное время, полученное с помощью TimeTradeServer(). Метод возвращает all_ok, чтобы вызывающий код мог обнаружить и записать в журнал частичный сбой инициализации, а не продолжать работу с неполными данными.

Refresh()

//+------------------------------------------------------------------+
//| Re-fetch metadata for every monitored symbol                     |
//+------------------------------------------------------------------+
void CSymbolMetaCache::Refresh(void)
  {
   for(int i = 0; i < m_n; i++)
      FetchMeta(i);

   m_last_refresh = TimeTradeServer();
   ::Print("CSymbolMetaCache::Refresh - refreshed ", m_n, " symbols at ",
           ::TimeToString(m_last_refresh, TIME_DATE | TIME_SECONDS));
  }

Метод Refresh() вызывает FetchMeta() для каждого индекса и заменяет ранее кэшированные значения свежими. Это единственный метод, кроме Init(), которому разрешено обращаться к API терминала. После завершения цикла обновляется m_last_refresh, а в журнал записывается подтверждающая строка с временной меткой обновления.

RefreshSymbol()

//+------------------------------------------------------------------+
//| Re-fetch metadata for a single symbol by name                    |
//+------------------------------------------------------------------+
bool CSymbolMetaCache::RefreshSymbol(const string &symbol)
  {
   int idx = FindIndex(symbol);

   if(idx < 0)
     {
      ::Print("CSymbolMetaCache::RefreshSymbol - symbol not in cache: ", symbol);
      return(false);
     }

   return(FetchMeta(idx));
  }

Метод RefreshSymbol() позволяет выборочно обновить данные кэша для одного символа. Метод ищет индекс символа. Если символ не найден, он записывает сообщение в журнал и возвращает false; в противном случае вызывает FetchMeta() для этого индекса.

Типизированные геттеры

CSymbolMetaCache предоставляет шесть геттеров, возвращающих значения простых типов: GetPoint(), GetDigits(), GetTickValue(), GetLotStep(), GetContractSize() и GetTradeMode(). Все эти геттеры работают по одной схеме: ищут индекс символа с помощью FindIndex(), возвращают безопасное значение по умолчанию, если символ не найден, и в противном случае возвращают кэшированное поле непосредственно из памяти. Ни один из них не обращается к API терминала. Метод GetPoint() — типичный пример этой схемы:

//+------------------------------------------------------------------+
//| Return the cached point value for a symbol                       |
//+------------------------------------------------------------------+
double CSymbolMetaCache::GetPoint(const string &symbol) const
  {
   int idx = FindIndex(symbol);
   if(idx < 0)
      return(0.0);

   return(m_meta[idx].point);
  }

Метод GetTradeMode() работает по той же схеме, но с одним важным отличием: если символ не найден, он возвращает SYMBOL_TRADE_MODE_DISABLED, а не числовой ноль.

/+------------------------------------------------------------------+
//| Return the cached trade mode for a symbol                        |
//+------------------------------------------------------------------+
ENUM_SYMBOL_TRADE_MODE CSymbolMetaCache::GetTradeMode(const string &symbol) const
  {
   int idx = FindIndex(symbol);
   if(idx < 0)
      return(SYMBOL_TRADE_MODE_DISABLED);

   return(m_meta[idx].trade_mode);
  }

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

IsMarketOpen()

//+------------------------------------------------------------------+
//| Determine whether a symbol's market is open at a given datetime  |
//+------------------------------------------------------------------+
bool CSymbolMetaCache::IsMarketOpen(const string &symbol, datetime dt) const
  {
   int idx = FindIndex(symbol);
   if(idx < 0 || !m_meta[idx].is_valid)
      return(false);

   MqlDateTime mdt;
   ::TimeToStruct(dt, mdt);

   ENUM_DAY_OF_WEEK dow = (ENUM_DAY_OF_WEEK)mdt.day_of_week;
   int seconds_today     = mdt.hour * 3600 + mdt.min * 60 + mdt.sec;
   int session_count     = ::ArraySize(m_meta[idx].sessions);

   for(int i = 0; i < session_count; i++)
     {
      if(m_meta[idx].sessions[i].day_of_week != dow)
         continue;

      if(seconds_today >= m_meta[idx].sessions[i].start_seconds &&
         seconds_today <  m_meta[idx].sessions[i].end_seconds)
         return(true);
     }

   return(false);
  }

Это вычисление полностью выполняется в памяти, как описано в разделе 3. Метод сразу возвращает false, если символ не кэширован или его запись недействительна. В противном случае метод преобразует переданное значение dt в структуру MqlDateTime с помощью TimeToStruct(), извлекает день недели и число секунд от полуночи, затем проверяет все закэшированные интервалы торговых сессий. Интервалы, относящиеся к другим дням недели, пропускаются. Для подходящего дня метод проверяет, входит ли seconds_today в полуинтервал [start_seconds, end_seconds). При первом совпадении метод возвращает true. Если ни один интервал не подходит, метод возвращает false. Этот метод нигде не обращается к API терминала.

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

Остальные методы доступа

//+------------------------------------------------------------------+
//| Copy out the full cached record for a symbol                     |
//+------------------------------------------------------------------+
bool CSymbolMetaCache::GetMeta(const string &symbol, SSymbolMeta &out_meta) const
  {
   int idx = FindIndex(symbol);
   if(idx < 0)
      return(false);

   out_meta = m_meta[idx];
   return(true);
  }

//+------------------------------------------------------------------+
//| Return the timestamp of the most recent full refresh             |
//+------------------------------------------------------------------+
datetime CSymbolMetaCache::GetLastRefreshTime(void) const
  {
   return(m_last_refresh);
  }

//+------------------------------------------------------------------+
//| Print the full cached record for every monitored symbol          |
//+------------------------------------------------------------------+
void CSymbolMetaCache::PrintAll(void) const
  {
   ::Print("CSymbolMetaCache::PrintAll - ", m_n, " symbols cached, last refresh ",
           ::TimeToString(m_last_refresh, TIME_DATE | TIME_SECONDS));

   for(int i = 0; i < m_n; i++)
      m_meta[i].Print();
  }

Три небольших метода завершают описание класса. Метод GetMeta() возвращает копию всей записи SSymbolMeta символа по значению. Это удобно коду, которому нужны дополнительные данные помимо тех, что возвращают отдельные геттеры, например для прямого перебора расписания торговых сессий. Метод GetLastRefreshTime() возвращает временную метку последнего полного обновления кэша. Метод PrintAll() записывает в журнал сводку, а затем выводит полную запись для каждого символа в кэше. В результате формируется диагностический вывод, показанный на рисунке 2. Ни один из этих трех методов не добавляет новой функциональности. Они лишь предоставляют доступ к данным, уже подготовленным методами Init(), Refresh() и FetchMeta().


Раздел 7 — реализация CacheDemo.mq5

//+------------------------------------------------------------------+
//|                                                    CacheDemo.mq5 |
//+------------------------------------------------------------------+

#include <SymbolMetaCache\SymbolMeta.mqh>
#include <SymbolMetaCache\SymbolMetaCache.mqh>

//--- Inputs
input string InpSymbol1 = "EURUSD";   // Symbol 1
input string InpSymbol2 = "GBPUSD";   // Symbol 2
input string InpSymbol3 = "USDJPY";   // Symbol 3
input string InpSymbol4 = "AUDUSD";   // Symbol 4
input string InpSymbol5 = "USDCAD";   // Symbol 5

CSymbolMetaCache g_cache;
int              g_last_logged_hour;

//+------------------------------------------------------------------+
//| Expert initialization function                                   |
//+------------------------------------------------------------------+
int OnInit()
  {
   string symbols[5];
   symbols[0] = InpSymbol1;
   symbols[1] = InpSymbol2;
   symbols[2] = InpSymbol3;
   symbols[3] = InpSymbol4;
   symbols[4] = InpSymbol5;

   if(!g_cache.Init(symbols, 5))
      Print("CacheDemo: OnInit - one or more symbols failed to initialize in the cache.");

   g_cache.PrintAll();

//--- fire once per hour; OnTimer checks for midnight boundary
   EventSetTimer(3600);
   g_last_logged_hour = -1;

   return(INIT_SUCCEEDED);
  }

//+------------------------------------------------------------------+
//| Expert tick function                                             |
//+------------------------------------------------------------------+
void OnTick()
  {
   MqlDateTime mdt;
   TimeToStruct(TimeTradeServer(), mdt);

//--- log once per hour to avoid flooding the Experts tab
   if(mdt.hour == g_last_logged_hour)
      return;

   g_last_logged_hour = mdt.hour;

   double point      = g_cache.GetPoint(_Symbol);
   double tick_value  = g_cache.GetTickValue(_Symbol);
   double lot_step    = g_cache.GetLotStep(_Symbol);
   bool   market_open = g_cache.IsMarketOpen(_Symbol, TimeTradeServer());

   PrintFormat("CacheDemo: %s  point=%.5f  tick_value=%.5f  lot_step=%.2f  market_open=%s",
               _Symbol, point, tick_value, lot_step,
               (market_open ? "true" : "false"));
  }

//+------------------------------------------------------------------+
//| Timer function                                                   |
//+------------------------------------------------------------------+
void OnTimer()
  {
   MqlDateTime mdt;
   TimeToStruct(TimeTradeServer(), mdt);

//--- refresh only at the midnight hour boundary
   if(mdt.hour != 0)
      return;

   datetime before = g_cache.GetLastRefreshTime();
   g_cache.Refresh();
   datetime after = g_cache.GetLastRefreshTime();

   PrintFormat("CacheDemo: OnTimer - daily refresh triggered.  before=%s  after=%s",
               TimeToString(before, TIME_DATE | TIME_SECONDS),
               TimeToString(after,  TIME_DATE | TIME_SECONDS));
  }

//+------------------------------------------------------------------+
//| Expert deinitialization function                                 |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
   EventKillTimer();
   PrintFormat("CacheDemo: OnDeinit - shutting down.  last_refresh=%s",
               TimeToString(g_cache.GetLastRefreshTime(), TIME_DATE | TIME_SECONDS));
  }
//+------------------------------------------------------------------+

g_cache объявлена на уровне файла, поэтому существует все время, пока советник прикреплен к графику. g_last_logged_hour ограничивает запись сообщений в журнал из обработчика тиков одним сообщением в час по серверному времени: так вкладка "Эксперты" остается читаемой и не переполняется сообщениями.

Функция OnInit() собирает пять символов из входных параметров в локальный массив, вызывает g_cache.Init() и записывает предупреждение в лог, если не удалось получить данные хотя бы по одному символу. При этом советник продолжает запуск. Затем вызывается метод PrintAll(), выводящий полный набор кэшированных метаданных по всем пяти символам. Вызов EventSetTimer(3600) задает ежечасный вызов обработчика таймера. g_last_logged_hour инициализируется значением -1, поэтому при первом тике сообщение записывается в лог независимо от текущего часа.

Функция OnTick() преобразует текущее серверное время в структуру MqlDateTime и сравнивает час с часом последней записи в лог. Если час не изменился, обработка тика прекращается. При смене часа через геттеры кэша считываются четыре значения и записываются в лог с помощью PrintFormat(); при этом вызовы SymbolInfo к терминалу не выполняются.

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

Функция OnDeinit() вызывает EventKillTimer(), чтобы остановить ежечасный таймер, и записывает в лог временную метку последнего обновления кэша.

CacheDemo.mq5 Experts tab output on startup.

Рисунок 2 — вывод CacheDemo.mq5 на вкладке "Эксперты" при запуске. Сразу после вызова OnInit() кэш выводит полный блок метаданных по каждому отслеживаемому символу. Это подтверждает, что все поля спецификации контракта и расписания сессий получены до прихода первого тика. Флаг is_valid=true в каждой записи подтверждает, что она успешно заполнена.


Раздел 8 — реализация CacheBenchmark.mq5

//+------------------------------------------------------------------+
//|                                               CacheBenchmark.mq5 |
//+------------------------------------------------------------------+

#property script_show_inputs

#include <SymbolMetaCache\SymbolMeta.mqh>
#include <SymbolMetaCache\SymbolMetaCache.mqh>

//--- Inputs
input int InpSymbolCount = 20;    // Number of symbols to benchmark
input int InpIterations  = 1000;  // Number of iterations per loop

//--- tolerance for tick value comparison (0.1% of the cached value)
//--- SYMBOL_TRADE_TICK_VALUE is recalculated in real time by the terminal
//--- for cross-currency pairs whose profit currency differs from the account
//--- currency. A strict absolute tolerance produces false FAIL results on
//--- those symbols as the live rate moves between cache init and verification.
#define TICK_VALUE_TOLERANCE_PCT 0.001

//+------------------------------------------------------------------+
//| Return true when the tick value comparison passes                |
//| Uses a percentage-based tolerance for cross-currency pairs       |
//+------------------------------------------------------------------+
bool TickValueMatches(const double cached, const double live)
  {
   if(cached == 0.0)
      return(live == 0.0);

   double pct_diff = MathAbs(live - cached) / MathAbs(cached);
   return(pct_diff <= TICK_VALUE_TOLERANCE_PCT);
  }

//+------------------------------------------------------------------+
//| Script program start function                                    |
//+------------------------------------------------------------------+
void OnStart(void)
  {
//--- collect symbols from Market Watch
   string symbols[];
   int    collected       = 0;
   int    total_available = SymbolsTotal(false);

   ArrayResize(symbols, InpSymbolCount);

   for(int i = 0; i < total_available && collected < InpSymbolCount; i++)
     {
      string name = SymbolName(i, false);
      if(!SymbolSelect(name, true))
         continue;

      symbols[collected] = name;
      collected++;
     }

   if(collected < InpSymbolCount)
      ArrayResize(symbols, collected);

   int symbol_count = collected;

   Print("CacheBenchmark: collected ", symbol_count, " symbols for benchmarking.");

//--- initialize the cache
   CSymbolMetaCache cache;
   cache.Init(symbols, symbol_count);

//--- accumulators prevent the compiler eliminating the loops as dead code
   double dummy_double = 0.0;
   int    dummy_int    = 0;

//--- uncached benchmark loop
   ulong uncached_start = GetMicrosecondCount();

   for(int iter = 0; iter < InpIterations; iter++)
     {
      for(int s = 0; s < symbol_count; s++)
        {
         dummy_double += SymbolInfoDouble(symbols[s], SYMBOL_POINT);
         dummy_double += SymbolInfoDouble(symbols[s], SYMBOL_TRADE_TICK_VALUE);
         dummy_double += SymbolInfoDouble(symbols[s], SYMBOL_VOLUME_STEP);
         dummy_int    += (int)SymbolInfoInteger(symbols[s], SYMBOL_DIGITS);
        }
     }

   ulong uncached_elapsed = GetMicrosecondCount() - uncached_start;

//--- cached benchmark loop
   ulong cached_start = GetMicrosecondCount();

   for(int iter = 0; iter < InpIterations; iter++)
     {
      for(int s = 0; s < symbol_count; s++)
        {
         dummy_double += cache.GetPoint(symbols[s]);
         dummy_double += cache.GetTickValue(symbols[s]);
         dummy_double += cache.GetLotStep(symbols[s]);
         dummy_int    += cache.GetDigits(symbols[s]);
        }
     }

   ulong cached_elapsed = GetMicrosecondCount() - cached_start;

//--- print checksum so the optimizer cannot eliminate the loops
   PrintFormat("CacheBenchmark: checksum (ignore)  double=%.4f  int=%d",
               dummy_double, dummy_int);

//--- derive metrics
   int    total_calls       = InpIterations * symbol_count;
   double uncached_per_iter = (double)uncached_elapsed / InpIterations;
   double cached_per_iter   = (double)cached_elapsed   / InpIterations;
   double uncached_per_call = (double)uncached_elapsed / total_calls;
   double cached_per_call   = (double)cached_elapsed   / total_calls;
   double speedup           = (cached_elapsed > 0)
                              ? ((double)uncached_elapsed / cached_elapsed)
                              : 0.0;

//--- print comparison table
   Print("=== CacheBenchmark Results ===");
   PrintFormat("Symbols: %d   Iterations: %d   Total calls per loop: %d",
               symbol_count, InpIterations, total_calls);
   PrintFormat("Uncached:  total=%I64u us   per_iter=%.2f us   per_symbol_per_iter=%.4f us",
               uncached_elapsed, uncached_per_iter, uncached_per_call);
   PrintFormat("Cached:    total=%I64u us   per_iter=%.2f us   per_symbol_per_iter=%.4f us",
               cached_elapsed, cached_per_iter, cached_per_call);
   PrintFormat("Speedup: %.2fx", speedup);

//--- correctness verification
//--- point, lot step, and digits use a strict absolute tolerance because
//--- they are genuinely static and never change tick-to-tick.
//--- tick value uses a percentage-based tolerance because the terminal
//--- recalculates it in real time for cross-currency pairs.
   Print("=== Correctness Verification ===");
   bool all_pass = true;

   for(int s = 0; s < symbol_count; s++)
     {
      double live_point      = SymbolInfoDouble(symbols[s], SYMBOL_POINT);
      double live_tick_value = SymbolInfoDouble(symbols[s], SYMBOL_TRADE_TICK_VALUE);
      double live_lot_step   = SymbolInfoDouble(symbols[s], SYMBOL_VOLUME_STEP);
      int    live_digits     = (int)SymbolInfoInteger(symbols[s], SYMBOL_DIGITS);

      bool point_ok      = (MathAbs(live_point    - cache.GetPoint(symbols[s]))   < 0.0000001);
      bool tick_value_ok = TickValueMatches(cache.GetTickValue(symbols[s]), live_tick_value);
      bool lot_step_ok   = (MathAbs(live_lot_step  - cache.GetLotStep(symbols[s])) < 0.0000001);
      bool digits_ok     = (live_digits == cache.GetDigits(symbols[s]));

      bool match = point_ok && tick_value_ok && lot_step_ok && digits_ok;

      if(!match)
        {
         all_pass = false;
         //--- report which specific field failed to aid diagnosis
         if(!point_ok)
            PrintFormat("  %s: point mismatch  cached=%.8f  live=%.8f",
                        symbols[s], cache.GetPoint(symbols[s]), live_point);
         if(!tick_value_ok)
            PrintFormat("  %s: tick_value drift  cached=%.8f  live=%.8f  pct=%.4f%%",
                        symbols[s], cache.GetTickValue(symbols[s]), live_tick_value,
                        MathAbs(live_tick_value - cache.GetTickValue(symbols[s])) /
                        MathAbs(cache.GetTickValue(symbols[s])) * 100.0);
         if(!lot_step_ok)
            PrintFormat("  %s: lot_step mismatch  cached=%.8f  live=%.8f",
                        symbols[s], cache.GetLotStep(symbols[s]), live_lot_step);
         if(!digits_ok)
            PrintFormat("  %s: digits mismatch  cached=%d  live=%d",
                        symbols[s], cache.GetDigits(symbols[s]), live_digits);
        }

      PrintFormat("%s: %s", symbols[s], (match ? "PASS" : "FAIL"));
     }

   PrintFormat("CacheBenchmark: overall result  %s", (all_pass ? "PASS" : "FAIL"));
  }
//+------------------------------------------------------------------+

Для перебора всех символов, предлагаемых брокером, используется функция SymbolsTotal() с аргументом false, а затем функция SymbolName() для получения имени каждого символа по индексу. Каждый символ-кандидат передается в функцию SymbolSelect() с аргументом true, что подтверждает допустимость символа и обеспечивает возврат актуальных данных функциями SymbolInfo*(). Символы, которые не удалось выбрать, пропускаются. Цикл завершается, когда набрано InpSymbolCount символов или исчерпан весь список.

Без кэширования внешний цикл выполняет InpIterations итераций; на каждой из них для каждого символа четыре раза вызываются функции SymbolInfo с прямым обращением к терминалу. Результаты накапливаются в переменных dummy_double и dummy_int, чтобы компилятор не распознал цикл как мертвый код и не удалил его. Время измеряется с помощью функции GetMicrosecondCount() непосредственно перед циклом и сразу после него.

Цикл с использованием кэша имеет идентичную структуру, но читает данные через геттеры CSymbolMetaCache; время его выполнения измеряется тем же способом.

В блоке проверки корректности стоимость тика обрабатывается иначе, чем остальные поля. Размер пункта, шаг объема и количество знаков после запятой сравниваются со строгим абсолютным допуском 0,0000001, поскольку эти поля действительно статичны и не должны меняться от тика к тику. Стоимость тика сравнивается с помощью вспомогательной функции TickValueMatches(), которая использует относительный допуск 0,1%. Это позволяет корректно учитывать реальное поведение кросс-валютных пар, для которых терминал непрерывно пересчитывает стоимость тика по мере изменения обменных курсов. Если какое-либо поле не проходит проверку, скрипт выводит диагностическую строку для каждого поля со значением из кэша, текущим значением и, в случае стоимости тика, фактическим отклонением в процентах. Благодаря этому сразу становится ясно, вызвано ли непрохождение проверки реальной ошибкой заполнения кэша или ожидаемым изменением, обусловленным рыночными факторами.


Раздел 9 — проверка TestSymbolMetaCache.mq5

//+------------------------------------------------------------------+
//|                                          TestSymbolMetaCache.mq5 |
//+------------------------------------------------------------------+

#include <SymbolMetaCache\SymbolMeta.mqh>
#include <SymbolMetaCache\SymbolMetaCache.mqh>

#define ASSERT(cond, label) \
   if(cond) Print("PASS: ", label); else Print("FAIL: ", label)

//+------------------------------------------------------------------+
//| Script program start function                                    |
//+------------------------------------------------------------------+
void OnStart(void)
  {
   string symbols[1];
   symbols[0] = _Symbol;

   CSymbolMetaCache cache;
   bool init_ok = cache.Init(symbols, 1);

   ASSERT(init_ok, "Init succeeds for current chart symbol");

//--- read live values for comparison
   double live_point      = SymbolInfoDouble(_Symbol, SYMBOL_POINT);
   double live_tick_value  = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_VALUE);
   double live_lot_step    = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_STEP);
   int    live_digits      = (int)SymbolInfoInteger(_Symbol, SYMBOL_DIGITS);
   double live_contract    = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_CONTRACT_SIZE);

//--- verify each getter against its live counterpart
   ASSERT(MathAbs(cache.GetPoint(_Symbol)        - live_point)      < 0.0000001,
          "GetPoint matches live SymbolInfoDouble");
   ASSERT(MathAbs(cache.GetTickValue(_Symbol)     - live_tick_value) < 0.0000001,
          "GetTickValue matches live value");
   ASSERT(MathAbs(cache.GetLotStep(_Symbol)       - live_lot_step)   < 0.0000001,
          "GetLotStep matches live value");
   ASSERT(cache.GetDigits(_Symbol) == live_digits,
          "GetDigits matches live value");
   ASSERT(MathAbs(cache.GetContractSize(_Symbol)  - live_contract)   < 0.0000001,
          "GetContractSize matches live value");

//--- verify GetMeta path
   SSymbolMeta meta;
   bool got_meta = cache.GetMeta(_Symbol, meta);
   ASSERT(got_meta,     "GetMeta returns true for a cached symbol");
   ASSERT(meta.is_valid, "GetMeta record reports is_valid == true");

//--- IsMarketOpen returns a definite boolean (correctness is time-dependent)
   bool market_open_now = cache.IsMarketOpen(_Symbol, TimeTradeServer());
   ASSERT(market_open_now == true || market_open_now == false,
          "IsMarketOpen returns a definite boolean");

//--- unknown symbol must fail safely
   string unknown = "ZZZZZ_NOT_REAL";
   ASSERT(cache.GetPoint(unknown)  == 0.0,   "GetPoint returns 0.0 for uncached symbol");
   ASSERT(!cache.IsMarketOpen(unknown, TimeTradeServer()),
          "IsMarketOpen returns false for uncached symbol");
   ASSERT(cache.GetTradeMode(unknown) == SYMBOL_TRADE_MODE_DISABLED,
          "GetTradeMode returns DISABLED for uncached symbol");

//--- RefreshSymbol on a cached symbol must succeed
   bool refresh_ok = cache.RefreshSymbol(_Symbol);
   ASSERT(refresh_ok, "RefreshSymbol succeeds for a known symbol");

//--- RefreshSymbol on an unknown symbol must fail gracefully
   bool refresh_fail = cache.RefreshSymbol(unknown);
   ASSERT(!refresh_fail, "RefreshSymbol returns false for unknown symbol");

   Print("TestSymbolMetaCache: all assertions complete.");
  }
//+------------------------------------------------------------------+

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

Макрос ASSERT выводит PASS или FAIL вместе с поясняющей меткой. Каждая проверка выполняется независимо, поэтому неудачный результат одной из них не препятствует выполнению остальных.

В ходе проверки результат каждого типизированного геттера сверяется с результатом соответствующего прямого вызова SymbolInfo, выполняемого непосредственно после инициализации кэша. Здесь для всех полей используется строгий абсолютный допуск, поскольку тест выполняется для одного символа: данные кэша сопоставляются с данными символа текущего графика. Скорее всего, это пара с долларом США, поэтому для нее не возникает проблема изменения стоимости тика, характерная для кросс-валютных пар. Тест проверяет корректность заполнения кэша, получение данных через GetMeta(), безопасные значения по умолчанию для неизвестных символов, а также успешный и неуспешный сценарии выполнения RefreshSymbol().

Для IsMarketOpen() проверяется лишь возврат логического значения, без требования конкретного результата true или false, поскольку тест не может контролировать, открыт ли рынок в момент его выполнения.


Раздел 10 — ожидаемые результаты теста производительности

При запуске скрипта CacheBenchmark.mq5 с параметрами InpSymbolCount = 20 и InpIterations = 1000 вывод имеет следующую структуру. Фактические значения времени будут различаться в зависимости от компьютера и нагрузки на терминал, но останутся в пределах диапазона, соответствующего модели затрат из раздела 1.

CacheBenchmark: collected 20 symbols for benchmarking.
CacheBenchmark: checksum (ignore)  double=47018.1079  int=188000
=== CacheBenchmark Results ===
Symbols: 20   Iterations: 1000   Total calls per loop: 20000
Uncached:  total=43213 us   per_iter=43.21 us   per_symbol_per_iter=2.1606 us
Cached:    total=4430 us   per_iter=4.43 us   per_symbol_per_iter=0.2215 us
Speedup: 9.75x
=== Correctness Verification ===
AUDCAD: PASS
AUDCHF: PASS
...
CacheBenchmark: overall result  PASS

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

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

Если для какого-либо символа получен результат FAIL, скрипт выводит диагностическую строку для каждого поля, указывая, какое именно свойство не совпало и насколько. Сообщение об отклонении tick_value менее чем на 0,1% указывает на ожидаемое движение рынка по кросс-валютной паре, а не на ошибку кэша. Несовпадение любого другого поля указывает на реальную проблему с заполнением кэша и требует анализа.


Раздел 11 — расширение кэша

Расширение кэша для поддержки ставок свопа предполагало бы добавление SYMBOL_SWAP_LONG, SYMBOL_SWAP_SHORT и SYMBOL_SWAP_ROLLOVER3DAYS в качестве дополнительных полей SSymbolMeta. Эти поля отличаются от существующих тем, что брокеры могут ежедневно корректировать ставки свопов и действительно это делают. Поэтому их следовало бы обновлять в рамках уже предусмотренного ежедневного обновления кэша, а не считать неизменными.

Механизм с флагом неактуальности данных позволил бы выполнять малозатратную проверку на каждом тике с помощью SymbolInfoInteger(symbol, SYMBOL_SELECT). Если отслеживаемый символ был удален из окна "Обзор рынка", одной этой проверки было бы достаточно, чтобы обнаружить это и вызвать RefreshSymbol() для данного символа. При этом на каждом тике для каждого символа по-прежнему выполнялся бы лишь один малозатратный вызов, а не полное получение данных за четыре вызова, поэтому накладные расходы, для устранения которых предназначен кэш, не возникли бы вновь.

Расширение для сохранения данных могло бы сериализовать весь массив m_meta[] в структурированный текстовый файл в папке MQL5/Files/ при вызове OnDeinit(), а при следующем вызове OnInit() — попытаться восстановить массив из файла и лишь в случае неудачи заново получить данные из терминала. Это позволило бы устранить затраты на получение данных при холодном запуске советников, часто перезапускаемых в процессе разработки, но потребовало бы проверки актуальности восстановленных данных.


Раздел 12 — ограничения

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

SYMBOL_TRADE_TICK_VALUE входит в число кэшируемых статических полей, и для пар, номинированных в долларах США, его значение действительно постоянно. Для кросс-валютных пар терминал непрерывно пересчитывает стоимость тика по мере изменения обменных курсов. При работе с диверсифицированным набором инструментов с разными валютами расчета кэшированные значения стоимости тика будут в течение дня отклоняться от текущих значений. Если логика советника требует точного значения стоимости тика — например, для расчета объема позиции в реальном времени, — вызывающий код должен либо обновлять стоимость тика чаще, чем другие поля, либо получать ее непосредственно из терминала и кэшировать только остальные свойства спецификации контракта.

При проверке интервалов торговых сессий в IsMarketOpen() предполагается, что переданное значение datetime задано в часовом поясе сервера брокера. Передача времени локального компьютера, полученного через TimeCurrent(), вместо серверного времени, полученного через TimeTradeServer(), приведет к неверным результатам без сообщения об ошибке, если эти часовые пояса различаются. Сообщение об ошибке не выдается, поскольку метод не может определить часовой пояс переданного значения.

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


Заключение

Мы создали компактный, готовый к использованию кэш метаданных символа, который заменяет повторяющиеся на каждом тике запросы к терминалу по каждому символу однократными затратами на инициализацию и последующими редкими обновлениями кэша. Порядок взаимодействия вызывающего кода с кэшем задан явно: один раз вызвать Init() при запуске для заполнения записей SSymbolMeta, использовать типизированные геттеры и IsMarketOpen() во время обычной работы советника без дополнительных вызовов SymbolInfo*() и вызывать Refresh() или RefreshSymbol() только тогда, когда требуется целенаправленно перепроверить кэшированные данные.

При этом ограничения реализации также обозначены явно. Значение SYMBOL_TRADE_TICK_VALUE для кросс-валютных инструментов может меняться, поэтому для точного расчета объема позиции может потребоваться чаще обновлять это значение или получать его непосредственно из терминала. IsMarketOpen() ожидает аргумент типа datetime, заданный в часовом поясе сервера, а кэш не обнаруживает автоматически изменения спецификации контракта на стороне брокера между запланированными обновлениями кэша.

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


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

# Название Тип Описание
1. SymbolMeta.mqh Подключаемый файл Определяет структуры SSessionWindow и SSymbolMeta, которые хранят соответственно один интервал торговой сессии и полную кэшированную спецификацию контракта для одного символа
2. SymbolMetaCache.mqh Подключаемый файл Реализует класс CSymbolMetaCache, который загружает метаданные символов один раз при запуске и обслуживает все последующие запросы на чтение из памяти
3. CacheDemo.mq5 Демонстрационный советник Демонстрирует работу кэша для пяти отслеживаемых символов с ежедневным обновлением по таймеру и ежечасной записью в лог кэшированных значений, считываемых без обращений к терминалу
4. CacheBenchmark.mq5 Скрипт Измеряет и сравнивает задержки при вызовах SymbolInfo без кэширования и при чтении из кэша для настраиваемого набора символов, а также проверяет корректность кэша, сравнивая его содержимое с актуальными значениями с учетом допусков для каждого поля
5. TestSymbolMetaCache.mq5 Скрипт Инициализирует кэш для символа текущего графика и проверяет, что значение, возвращаемое каждым геттером, совпадает с соответствующим актуальным значением SymbolInfo; тем самым проверяются корректность заполнения кэша и безопасное поведение при обращении к неизвестным символам
6. Symbol_Metadata_Cache.zip ZIP-архив ZIP-архив, содержащий все прикрепленные файлы и пути к ним относительно корневой папки терминала.

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

Прикрепленные файлы |
SymbolMeta.mqh (2.77 KB)
SymbolMetaCache.mqh (11.32 KB)
CacheDemo.mq5 (3.43 KB)
CacheBenchmark.mq5 (7.28 KB)
Последние комментарии | Перейти к обсуждению на форуме трейдеров (1)
fxsaber
fxsaber | 2 нояб. 2026 в 12:20
Статья о том, что в MQL5 можно использовать переменные для почти const-свойств символа.
   m_meta[idx].symbol         = symbol;
   m_meta[idx].digits         = (int)::SymbolInfoInteger(symbol, SYMBOL_DIGITS);
   m_meta[idx].point          = ::SymbolInfoDouble(symbol, SYMBOL_POINT);
   m_meta[idx].tick_size      = ::SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_SIZE);
   m_meta[idx].tick_value     = ::SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_VALUE);
   m_meta[idx].lot_step       = ::SymbolInfoDouble(symbol, SYMBOL_VOLUME_STEP);
   m_meta[idx].lot_min        = ::SymbolInfoDouble(symbol, SYMBOL_VOLUME_MIN);
   m_meta[idx].lot_max        = ::SymbolInfoDouble(symbol, SYMBOL_VOLUME_MAX);
   m_meta[idx].contract_size  = ::SymbolInfoDouble(symbol, SYMBOL_TRADE_CONTRACT_SIZE);
   m_meta[idx].margin_initial = ::SymbolInfoDouble(symbol, SYMBOL_MARGIN_INITIAL);
   m_meta[idx].currency_base   = ::SymbolInfoString(symbol, SYMBOL_CURRENCY_BASE);
   m_meta[idx].currency_profit = ::SymbolInfoString(symbol, SYMBOL_CURRENCY_PROFIT);
   m_meta[idx].currency_margin = ::SymbolInfoString(symbol, SYMBOL_CURRENCY_MARGIN);
   m_meta[idx].trade_mode      = (ENUM_SYMBOL_TRADE_MODE)::SymbolInfoInteger(symbol, SYMBOL_TRADE_MODE);

Проблема полностью придумана, не имеет ничего общего с реальностью. Это точно человек написал статью?

Разработка индикатора снятия ликвидности  STF Liquidity Sweep на языке MQL5 Разработка индикатора снятия ликвидности STF Liquidity Sweep на языке MQL5
В статье представлен динамический индикатор для MetaTrader 5, который выявляет снятия ликвидности используя логику определения точек разворота, соотношение теней свечей и подтверждения от паттернов поглощения. Индикатор распознает теневые паттерны с одной и двумя свечами без фиксированного временного окна, обновляет уровни ликвидности со стороны покупателей и продавцов по мере изменения цены и аннулирует уже пробитые уровни для поддержания достоверной карты ликвидности.
Фундаментальные модели для торговли (Часть I): Нативная реализация Kronos средствами MQL5 Фундаментальные модели для торговли (Часть I): Нативная реализация Kronos средствами MQL5
Kronos — предварительно обученная модель-трансформер, которая моделирует бары OHLCV так же, как языковая модель предсказывает слова. Мы с нуля реализуем ее токенизатор/кодировщик и блок трансформера на нативном MQL5, экспортируем веса в плоские файлы .bin и полностью исключаем Python из этапа выполнения модели. В части 1 реализованы предобработка, токенизация BSQ и средства побитной проверки результатов относительно PyTorch, что позволяет уверенно запускать кодировщик внутри MetaTrader 5.
Метаразметка классических стратегий (Часть 1): Фильтрация сделок по RSI и определение размера позиций Метаразметка классических стратегий (Часть 1): Фильтрация сделок по RSI и определение размера позиций
В условиях тренда RSI накапливает убытки, поскольку генерирует сигнал при каждом пересечении порога независимо от рыночного режима. Вторичный классификатор на основе случайного леса, обученный на 12 контекстных признаках — наклоне импульса RSI, скорости тренда EMA50, нормированном по ATR отклонении цены от трендовой средней и девяти других, — фильтрует исходные сигналы и масштабирует размер позиции в зависимости от уверенности классификатора на данных EURUSD H1. В результатах сравниваются исходный RSI, RSI с метафильтрацией и RSI с адаптивным размером позиции за 16-месячный период вне обучающей выборки с анализом показателей по отдельным сделкам и просадок.
Создание системы торговой аналитики (Часть 4): Сводные метрики и дашборд Создание системы торговой аналитики (Часть 4): Сводные метрики и дашборд
В этой статье мы расширяем существующий бэкенд Flask, чтобы рассчитывать аналитические показатели эффективности на основе сохраненных закрытых сделок MetaTrader 5 и предоставлять их как в формате JSON, так и в виде простого веб-представления. Система рассчитывает общее количество сделок, общую прибыль, долю прибыльных сделок, среднюю прибыль и метрики продолжительности сделок, возвращает JSON по адресу /api/v1/analytics/summary и отображает дашборд по адресу /api/v1. В результате появляется быстрый и единообразный способ анализировать эффективность торговли на основе сохраненных записей SQLite.