Персистентное хранилище "ключ — значение" в MQL5: Плоские файлы как легковесная база данных для хранения состояния советника
Введение
При каждом перезапуске терминала оперативная память торгового советника (EA) очищается без каких-либо уведомлений. Счётчики сбрасываются, флаги исчезают, и выполнение возобновляется так, как будто советник только что был подключён к графику. Для простых систем это может быть лишь неудобством, но в профессиональных торговых системах потеря состояния может нарушить работу системы управления рисками и логику исполнения, а также лишить советник ценной информации, накопленной за предыдущие сеансы.
Торговые советники MetaTrader 5 работают в среде выполнения, полностью управляемой торговым терминалом. При закрытии терминала очищается всё временное состояние программы, существующее во время её работы, включая счётчики, флаги и временные метки сеанса. При инициализации советник возвращается к состоянию по умолчанию, заданному при компиляции. Такое поведение является стандартной особенностью управляемых сред выполнения, а не недостатком среды выполнения MQL5. Архитектурные уязвимости возникают, если советник хранит рабочее состояние только в глобальных переменных программы и не располагает независимым механизмом сохранения данных для восстановления этого состояния после завершения работы.
В условиях реальной эксплуатации отсутствие структурированного слоя сохранения данных обычно сказывается на нескольких критически важных аспектах работы:
- Показатели сеанса и счетчики сделок: целевые значения совокупной реализованной прибыли, индикаторы просадки, а также дневные или недельные лимиты по количеству сделок обнуляются при перезапуске терминала, что может привести к возобновлению работы советника с нарушением заданных параметров риска сеанса.
- Состояния определения рыночного режима: флаги структурной классификации рынка (например, признаки трендового режима или режима возврата к среднему, фильтры волатильности и состояния корреляции активов) стираются, вынуждая систему заново классифицировать текущую рыночную обстановку.
- Временные метки оптимизации: советники, настроенные на запуск автономной оптимизации параметров по фиксированному расписанию, теряют историю выполнения, что приводит к ненужным ресурсоемким повторным оптимизациям при перезапуске.
- Динамические настройки риска и переключатели функциональности: переменные, определяющие множители размера позиции, показатели использования маржи и условные фильтры, действующие во время выполнения (например, блокировку торговли на время выхода новостей или пороговые значения спреда), исчезают, нарушая непрерывность управления рисками.
Эти переменные отражают основное рабочее состояние, необходимое для надежного выполнения алгоритма. Без явного механизма сохранения данных каждое прерывание работы терминала приводит к сбросу, эквивалентному развертыванию нового экземпляра без контекста предыдущих сеансов.
Вместо использования SQLite, глобальных переменных терминала или внешних механизмов хранения данных в этой статье разрабатывается легковесный слой сохранения данных, полностью реализованный штатными средствами MQL5. Это решение сочетает понятный текстовый формат плоского файла с типизированным API, кэшем в оперативной памяти и модульной архитектурой хранения данных, которая обеспечивает сохранение состояния советника между перезапусками терминала. Его легко анализировать, расширять и интегрировать в существующие проекты.
В статье рассматриваются архитектурные подходы к созданию в MQL5 надёжного слоя сохранения состояния, обеспечивающего предсказуемую работу советника после перезапуска терминала.
Почему глобальных переменных терминала недостаточно
MQL5 предоставляет функции GlobalVariableSet() и GlobalVariableGet() в качестве встроенного механизма для хранения именованных значений между экземплярами советников. Глобальные переменные терминала сохраняются на диске в собственном двоичном формате, управляемом терминалом. На первый взгляд они решают проблему, однако накладывают ряд ограничений, которые снижают их полезность в системах, используемых в реальной эксплуатации.
Глобальные переменные терминала используют единое пространство имен, общее для всех советников, запущенных в одном терминале. Советник, сохраняющий trade_counter как глобальную переменную терминала, перезапишет или прочитает значение, сохраненное под тем же именем любым другим советником. Разделение пространств имен по номеру счета или имени советника требует формирования строк ключей во время выполнения и влечет дополнительные затраты на координацию.
Глобальные переменные терминала поддерживают только значения типа double. Советник, которому необходимо сохранить строку, например "conservative" для режима риска, значение типа datetime или логический флаг, должен закодировать это значение в виде double и учитывать принятые правила кодирования. Такое кодирование ненадежно и непрозрачно.
Глобальные переменные терминала невозможно просмотреть без дополнительного кода или инструментов, поскольку терминал сохраняет их на диске в формате, не предназначенном для просмотра. Для отладки необходимо написать дополнительный диагностический код, который считывает и выводит эти данные. Плоский файл, хранящийся в папке MQL5/Files/, можно открыть в любом текстовом редакторе и сразу просмотреть его содержимое.
Глобальные переменные терминала не предусматривают группировки или разделения пространств имен по экземплярам советников. Плоский файл естественным образом обеспечивает изоляцию данных каждого советника, поскольку каждый советник может хранить свои данные в отдельном файле с уникальным именем.
Плоские файлы в качестве слоя сохранения данных
В плоском файле состояние советника хранится в виде последовательности понятных текстовых строк следующего формата:
daily_target_hit=true trade_counter=42 last_optimization=2025.05.01 09:00 risk_mode=conservative
Каждая строка кодирует одну пару "ключ — значение". Ключ представляет собой строковый идентификатор. Значение представляет собой строковое представление данных, которые по смыслу могут соответствовать целому числу, числу типа double, логическому значению, дате и времени или обычной строке. Файл размещается в каталоге MQL5/Files/ — единственном месте, где разрешено работать файловым функциям ввода-вывода MQL5.
Формат легко читать и использовать в разных средах. Он не требует внешних библиотек, импорта DLL или движка базы данных. Разработчик может просмотреть или вручную исправить состояние в любом текстовом редакторе. Формат легко разбирается с помощью разбиения строк, для которого в MQL5 предусмотрена встроенная функция StringSplit().
Ограничения тоже существуют. В формате отсутствует контроль соответствия схеме — в качестве любого значения можно сохранить произвольную строку, а согласованность типов должна обеспечиваться прикладным слоем. Последовательные обновления требуют перезаписи всего файла, поскольку данный формат не поддерживает замену записей на месте. Одновременный доступ нескольких экземпляров советника, записывающих данные в один и тот же файл, небезопасен. Эти компромиссы подробно анализируются в следующих разделах.
Обзор архитектуры
Реализация состоит из пяти модулей в виде заголовочных файлов и одного демонстрационного советника, каждый из которых отвечает за отдельный слой системы.
| Слой | Модуль | Назначение |
|---|---|---|
| Представление значений | PersistentValue.mqh | Вариантный тип со вспомогательными функциями определения типа |
| Синтаксический анализ | PersistentParser.mqh | Токенизация и проверка строк |
| Сериализация | PersistentSerializer.mqh | Преобразование записей кэша в текст для плоского файла |
| Кэш | PersistentCache.mqh | Хранилище в оперативной памяти на основе CHashMap |
| Интерфейс хранилища | PersistentStore.mqh | Публичный API — Get, Set, Delete, Exists, синхронизация с файлом |
| Демонстрация | PersistentStoreDemo.mq5 | Полный пример использования и имитация перезапуска |
Слои хранения и связи между ними:
| Слой | Компонент | Назначение |
|---|---|---|
| Диск | Плоский файл.kv в папке MQL5/Files/ | Сохранение данных между перезапусками |
| Память | CHashMap<string,string> | Кэш с временем доступа O(1) |
| Интерфейс | CPersistentStore | Единый API для кода советника |
Архитектура "ключ — значение"
Каждая сохраненная запись состоит из двух частей:
| Компонент | Тип | Назначение |
|---|---|---|
| Ключ | строка | Уникальный идентификатор сохраненного значения |
| Значение | строка | Сериализованное представление любого поддерживаемого типа |
Ключи должны представлять собой непустые строки, не содержащие символ "=", поскольку "=" используется в качестве разделителя полей. Значения хранятся в виде строк независимо от их логического типа. Определение типа выполняется при извлечении значения, когда вызывающий код запрашивает конкретный тип, либо автоматически во время синтаксического анализа.
Пять основных операций и их сложность:
| Операция | Сложность операций с кэшем | Сложность операций с диском |
|---|---|---|
| Get() | Поиск в хеш-таблице со сложностью O(1) | Без обращения к диску |
| Exists() | Поиск в хеш-таблице со сложностью O(1) | Без обращения к диску |
| Set() | Вставка в хеш-таблицу со сложностью O(1) | Полная перезапись файла — O(n) |
| Delete() | Удаление из хеш-таблицы со сложностью O(1) | Полная перезапись файла — O(n) |
| Load() | O(n) | Последовательное чтение файла — O(n) |
Затраты на дисковые операции при выполнении Set() и Delete() составляют O(n) и неизбежны из-за последовательного формата плоского файла. Однако файлы состояния советников обычно невелики и редко содержат более нескольких сотен записей, поэтому такие затраты приемлемы. Файл, содержащий 100 записей по 40 байт в каждой строке, занимает 4 КБ. Полная перезапись 4 КБ занимает значительно меньше одной миллисекунды на любом современном носителе.
Листинги кода и разбор архитектуры
PersistentValue.mqh
CPersistentValue — это класс-обёртка, следующий принципу единственной ответственности. Он хранит одну строку и предоставляет типизированные методы доступа, выполняющие преобразование по мере необходимости. Этот класс никогда не обращается к диску — он лишь моделирует, что такое значение и какие типы оно может представлять.
Поддерживаемые типы объявлены в виде перечисления в начале файла, чтобы вызывающий код мог при необходимости выполнять ветвление по определенному типу значения:
//+------------------------------------------------------------------+ //| PersistentValue.mqh | //| Lightweight Persistent Key-Value Storage Engine | //+------------------------------------------------------------------+ #ifndef PERSISTENT_VALUE_MQH #define PERSISTENT_VALUE_MQH //+------------------------------------------------------------------+ //| Supported value types for type-safe retrieval | //+------------------------------------------------------------------+ enum ENUM_PERSISTENT_TYPE { PERSISTENT_TYPE_STRING = 0, // Raw string (default) PERSISTENT_TYPE_INT = 1, // 32-bit integer PERSISTENT_TYPE_LONG = 2, // 64-bit integer PERSISTENT_TYPE_DOUBLE = 3, // Floating-point number PERSISTENT_TYPE_BOOL = 4, // Boolean true/false PERSISTENT_TYPE_DATETIME = 5 // Datetime in YYYY.MM.DD HH:MM format };
Шесть элементов перечисления ENUM_PERSISTENT_TYPE точно соответствуют шести типам, поддерживаемым хранилищем. Элементу STRING присвоено значение 0, и он используется по умолчанию. К этому типу относится любое значение, не соответствующее более конкретному шаблону. Явное задание целочисленных констант для всех элементов перечисления не позволяет компилятору изменять их значения при перестановке элементов.
Далее следует объявление класса. Он содержит два закрытых поля и один закрытый метод, а также открытый API, состоящий из одного метода заполнения данными и шести типизированных методов доступа:
//+------------------------------------------------------------------+ //| CPersistentValue | //| Wraps a raw string value together with its inferred type. | //| All values are stored internally as strings; conversion is | //| performed on demand when a typed accessor is called. | //+------------------------------------------------------------------+ class CPersistentValue { private: string m_raw; // Raw string representation ENUM_PERSISTENT_TYPE m_type; // Inferred or assigned type ENUM_PERSISTENT_TYPE InferType(const string raw); public: CPersistentValue(void); ~CPersistentValue(void); void FromString(const string raw); string AsString(void) const; int AsInt(void) const; long AsLong(void) const; double AsDouble(void) const; bool AsBool(void) const; datetime AsDatetime(void) const; ENUM_PERSISTENT_TYPE Type(void) const; };
m_raw хранит значение в том виде, в каком оно было прочитано из плоского файла или передано вызывающим кодом. m_type кэширует определенный тип, благодаря чему метод Type() возвращает его без повторного определения. Метод InferType() является закрытым, поскольку внешнему коду не требуется классифицировать строку — эта логика полностью сосредоточена внутри данного класса.
//+------------------------------------------------------------------+ //| Constructor | //+------------------------------------------------------------------+ CPersistentValue::CPersistentValue(void) { m_raw = ""; m_type = PERSISTENT_TYPE_STRING; } //+------------------------------------------------------------------+ //| Destructor | //+------------------------------------------------------------------+ CPersistentValue::~CPersistentValue(void) { }
Конструктор инициализирует m_raw пустой строкой, а m_type — значением PERSISTENT_TYPE_STRING. Это обеспечивает безопасное состояние по умолчанию. Экземпляр, для которого никогда не вызывался метод FromString(), ведет себя так, как будто он содержит пустую строку. Деструктору нечего делать, поскольку m_raw — строка, хранящаяся по значению и управляемая средой выполнения MQL5, а m_type — обычное значение перечисления.
//+------------------------------------------------------------------+ //| Populate value from a raw string and infer its type | //+------------------------------------------------------------------+ void CPersistentValue::FromString(const string raw) { m_raw = raw; m_type = InferType(raw); }
Это единственная точка входа для заполнения экземпляра данными. Здесь сохраняется исходная строка и сразу же вызывается метод InferType() для определения её типа. Определенный тип кэшируется в m_type, чтобы не определять его повторно в методе Type(). Типизированные методы доступа не используют m_type при преобразованиях. Они всегда считывают данные непосредственно из m_raw, поэтому несоответствие между m_type и фактическим значением никогда не приводит к неверному результату метода доступа. m_type нужен исключительно для того, чтобы вызывающий код мог узнать результат классификации без вызова метода преобразования.
//+------------------------------------------------------------------+ //| Infer the logical type of a raw string value | //+------------------------------------------------------------------+ ENUM_PERSISTENT_TYPE CPersistentValue::InferType(const string raw) { //--- check for boolean literals if(raw == "true" || raw == "false") return(PERSISTENT_TYPE_BOOL); //--- check for datetime pattern YYYY.MM.DD HH:MM (length == 16) if(StringLen(raw) == 16) { if(raw[4] == '.' && raw[7] == '.' && raw[10] == ' ' && raw[13] == ':') return(PERSISTENT_TYPE_DATETIME); } //--- check for presence of decimal point indicating double if(StringFind(raw, ".") >= 0) { double parsed = StringToDouble(raw); string round_trip = DoubleToString(parsed, 5); if(StringFind(round_trip, ".") >= 0) return(PERSISTENT_TYPE_DOUBLE); } //--- check for pure integer via round-trip long parsed_long = StringToInteger(raw); string round_trip = IntegerToString(parsed_long); if(round_trip == raw) { if(parsed_long >= -2147483648 && parsed_long <= 2147483647) return(PERSISTENT_TYPE_INT); return(PERSISTENT_TYPE_LONG); } //--- default to string return(PERSISTENT_TYPE_STRING); }
Метод InferType() применяет правила определения типа в строгом порядке приоритетов. Сначала проверяется логический тип, поскольку иначе строки "true" и "false" дошли бы до проверки на целочисленный тип и при преобразовании туда и обратно дали бы допустимый результат "ноль", что привело бы к неверной классификации. Вторым проверяется тип datetime: вместо разбора формата выполняется структурная проверка длины строки и символов, поскольку в MQL5 нет встроенной поддержки регулярных выражений. Проверка длины перед проверкой символов в заданных позициях позволяет избежать выхода индекса за границы короткой строки.
Для чисел типа double код сначала проверяет наличие десятичной точки, затем вызывает StringToDouble() и проверяет результат с помощью преобразования туда и обратно. При этом отбрасываются такие строки, как "NaN", которые функция StringToDouble() преобразовала бы без ошибок. При определении целочисленного типа используется та же стратегия преобразования туда и обратно: преобразование в тип long, обратное преобразование в строку и сравнение с исходной строкой. Строка вида "42abc" преобразуется в число 42, но при обратном преобразовании получается "42", что не совпадает с исходной строкой "42abc", поэтому ей корректно присваивается тип PERSISTENT_TYPE_STRING.
//+------------------------------------------------------------------+ //| Return the raw string value | //+------------------------------------------------------------------+ string CPersistentValue::AsString(void) const { return(m_raw); } //+------------------------------------------------------------------+ //| Return value interpreted as a 32-bit integer | //+------------------------------------------------------------------+ int CPersistentValue::AsInt(void) const { return((int)StringToInteger(m_raw)); } //+------------------------------------------------------------------+ //| Return value interpreted as a 64-bit integer | //+------------------------------------------------------------------+ long CPersistentValue::AsLong(void) const { return(StringToInteger(m_raw)); } //+------------------------------------------------------------------+ //| Return value interpreted as a double-precision float | //+------------------------------------------------------------------+ double CPersistentValue::AsDouble(void) const { return(StringToDouble(m_raw)); } //+------------------------------------------------------------------+ //| Return value interpreted as a boolean | //+------------------------------------------------------------------+ bool CPersistentValue::AsBool(void) const { return(m_raw == "true"); } //+------------------------------------------------------------------+ //| Return value interpreted as a datetime | //+------------------------------------------------------------------+ datetime CPersistentValue::AsDatetime(void) const { return(StringToTime(m_raw)); } //+------------------------------------------------------------------+ //| Return the inferred type of this value | //+------------------------------------------------------------------+ ENUM_PERSISTENT_TYPE CPersistentValue::Type(void) const { return(m_type); }
Каждый типизированный метод доступа выполняет преобразование непосредственно из m_raw в момент вызова. Промежуточные результаты преобразования не кэшируются, благодаря чему объем памяти, занимаемый экземпляром, ограничивается одной строкой и одним значением перечисления. Метод AsInt() приводит результат вызова функции StringToInteger(), которая всегда возвращает значение типа long, к типу int. Метод AsBool() выполняет прямое сравнение строки со значением "true", а не рассматривает любое ненулевое значение как истинное, что позволяет сохранить строгую булеву семантику, определённую методом SerializeBool() в сериализаторе. Метод AsDatetime() делегирует преобразование функции StringToTime(), которая принимает строки в формате YYYY.MM.DD HH:MM, формируемом методом SerializeDatetime().
PersistentParser.mqh
CPersistentParser выполняет всю обработку текста, необходимую для преобразования необработанной строки из плоского файла в проверенную пару "ключ — значение". Все методы этого класса являются статическими — синтаксический анализ представляет собой чистое преобразование входной строки в выходные значения, при котором между вызовами не сохраняется никакое состояние. Создавать экземпляр этого класса не требуется.
//+------------------------------------------------------------------+ //| PersistentParser.mqh | //| Lightweight Persistent Key-Value Storage Engine | //+------------------------------------------------------------------+ #ifndef PERSISTENT_PARSER_MQH #define PERSISTENT_PARSER_MQH //+------------------------------------------------------------------+ //| CPersistentParser | //| Responsible for parsing individual lines of the flat file into | //| key-value pairs. Handles whitespace trimming, comment skipping, | //| empty-line detection, and delimiter validation. | //+------------------------------------------------------------------+ class CPersistentParser { private: static string TrimWhitespace(const string s); public: CPersistentParser(void); ~CPersistentParser(void); static bool ParseLine(const string line, string &out_key, string &out_value); static bool IsCommentOrEmpty(const string line); static bool IsValidKey(const string key); };
Метод TrimWhitespace() объявлен как private, поскольку это деталь реализации, используемая внутри методов IsCommentOrEmpty() и ParseLine(). Два вспомогательных метода проверки — IsCommentOrEmpty() и IsValidKey() — сделаны открытыми, чтобы вызывающий код мог при необходимости выполнять предварительные проверки без вызова ParseLine(). Открытой точкой входа для обычного использования служит метод ParseLine().
//+------------------------------------------------------------------+ //| Constructor | //+------------------------------------------------------------------+ CPersistentParser::CPersistentParser(void) { } //+------------------------------------------------------------------+ //| Destructor | //+------------------------------------------------------------------+ CPersistentParser::~CPersistentParser(void) { }
Конструктор и деструктор пусты: класс не содержит полей данных и не выделяет ресурсов. Они определены явно в соответствии с соглашением MetaQuotes по написанию кода, которое требует, чтобы каждый класс объявлял конструктор и деструктор, даже если их тела пусты.
//+------------------------------------------------------------------+ //| Remove leading and trailing whitespace from a string | //+------------------------------------------------------------------+ string CPersistentParser::TrimWhitespace(const string s) { string result = s; int len = StringLen(result); //--- trim leading whitespace int start = 0; while(start < len && (result[start] == ' ' || result[start] == '\t')) start++; //--- trim trailing whitespace and carriage returns int end = len - 1; while(end >= start && (result[end] == ' ' || result[end] == '\t' || result[end] == '\r' || result[end] == '\n')) end--; if(start > end) return(""); return(StringSubstr(result, start, end - start + 1)); }
Метод TrimWhitespace() является основой, на которой построены все остальные методы. Метод сдвигает start вправо, пока в начале строки встречаются пробелы или символы табуляции, а end — влево, пока в конце строки встречаются пробелы, символы табуляции, возврата каретки или новой строки. Явная обработка символа \r необходима: функция FileReadString() в режиме FILE_TXT удаляет символ \n из окончаний строк, но в файлах, созданных в Windows, символ \r, предшествующий \n, остаётся. Если ключ считан как "risk_mode\r", сравнение на равенство с "risk_mode" даст отрицательный результат без сообщения об ошибке, и ключ не будет найден. Если после обоих проходов start превышает end, значит, строка целиком состоит из пробельных символов, и возвращается пустая строка.
//+------------------------------------------------------------------+ //| Return true if line is empty or starts with '#' | //+------------------------------------------------------------------+ bool CPersistentParser::IsCommentOrEmpty(const string line) { string trimmed = TrimWhitespace(line); //--- skip empty lines if(StringLen(trimmed) == 0) return(true); //--- skip comment lines if(trimmed[0] == '#') return(true); return(false); }
Этот метод выступает в качестве первого фильтра в цепочке синтаксического анализа. Перед проверкой метод удаляет пробельные символы по краям входной строки, поэтому строка, содержащая только пробелы, корректно распознаётся как пустая. Если первый непробельный символ строки #, строка считается комментарием. Оба условия сигнализируют функции ParseLine(), что строка не содержит данных и её следует пропустить, не пытаясь разбивать на части.
//+------------------------------------------------------------------+ //| Return true if key contains no '=' character and is non-empty | //+------------------------------------------------------------------+ bool CPersistentParser::IsValidKey(const string key) { if(StringLen(key) == 0) return(false); if(StringFind(key, "=") >= 0) return(false); return(true); }
Этот метод обеспечивает соблюдение двух структурных правил для ключей. Ключ не должен быть пустым и не должен содержать символ "=". Символ = запрещён, поскольку в данном формате файла он используется в качестве единственного разделителя полей. Ключ, содержащий символ "=", приведет к неоднозначности повторного синтаксического анализа при следующей загрузке файла — синтаксический анализатор разбивает строку на части при первом же обнаружении символа "=", поэтому ключ типа a=b, сохраненный в виде a=b=value, будет прочитан с ключом a и значением b=value, что приведет к незаметному повреждению записи.
//+------------------------------------------------------------------+ //| Parse a single line into key and value components | //+------------------------------------------------------------------+ bool CPersistentParser::ParseLine(const string line, string &out_key, string &out_value) { out_key = ""; out_value = ""; //--- skip comments and blank lines if(IsCommentOrEmpty(line)) return(false); //--- locate the first '=' delimiter int eq_pos = StringFind(line, "="); if(eq_pos < 1) return(false); // No delimiter or empty key //--- extract key as substring before '=' out_key = StringSubstr(line, 0, eq_pos); out_key = TrimWhitespace(out_key); //--- extract value as everything after '=' out_value = StringSubstr(line, eq_pos + 1); out_value = TrimWhitespace(out_value); //--- validate that the extracted key is well-formed if(!IsValidKey(out_key)) { out_key = ""; out_value = ""; return(false); } return(true); }
ParseLine() — это общедоступная точка входа, которая объединяет все предыдущие методы в одну целостную операцию. Выходные параметры очищаются в начале, чтобы при возврате false вызывающий код всегда получал пустые строки, а не устаревшие данные от предыдущего вызова. Метод находит первый символ "=" с помощью StringFind() и разбивает строку в этом месте. Использование первого вхождения вместо последнего означает, что сами значения могут содержать символы = — например, base64_key=abc=def== корректно преобразуется в ключ base64_key и значение abc=def==. Пробельные символы по краям извлечённых ключа и значения удаляются независимо друг от друга, что позволяет обработать пробелы, которые могли появиться вокруг них при ручном редактировании файла.
PersistentSerializer.mqh
CPersistentSerializer является зеркальным аналогом CPersistentParser. Если синтаксический анализатор считывает строки и формирует типизированные значения, то сериализатор принимает типизированные значения и формирует строки, пригодные для записи в плоский файл. Как и у парсера, все методы этого класса статические, поэтому создавать его экземпляр не требуется.
//+------------------------------------------------------------------+ //| PersistentSerializer.mqh | //| Lightweight Persistent Key-Value Storage Engine | //+------------------------------------------------------------------+ #ifndef PERSISTENT_SERIALIZER_MQH #define PERSISTENT_SERIALIZER_MQH //+------------------------------------------------------------------+ //| CPersistentSerializer | //| Converts typed values to their canonical string representations | //| for storage in the flat file, and assembles complete file | //| content from parallel arrays of key-value string pairs. | //+------------------------------------------------------------------+ class CPersistentSerializer { public: CPersistentSerializer(void); ~CPersistentSerializer(void); static string SerializeInt(const int value); static string SerializeLong(const long value); static string SerializeDouble(const double value, const int digits = 8); static string SerializeBool(const bool value); static string SerializeDatetime(const datetime value); static string SerializeString(const string value); static string FormatLine(const string key, const string value); static bool WriteLines(const int handle, const string &keys[], const string &values[], const int count); };
Шесть методов Serialize*() однозначно соответствуют шести типам из ENUM_PERSISTENT_TYPE. Методы FormatLine() и WriteLines() отвечают за запись в файл отдельно от логики преобразования значений, разделяя эти задачи.
//+------------------------------------------------------------------+ //| Constructor | //+------------------------------------------------------------------+ CPersistentSerializer::CPersistentSerializer(void) { } //+------------------------------------------------------------------+ //| Destructor | //+------------------------------------------------------------------+ CPersistentSerializer::~CPersistentSerializer(void) { }
Оба пусты по той же причине, что и в CPersistentParser. Этот класс представляет собой исключительно набор статических функций и не содержит состояния экземпляра.
//+------------------------------------------------------------------+ //| Serialize an integer to its string representation | //+------------------------------------------------------------------+ string CPersistentSerializer::SerializeInt(const int value) { return(IntegerToString(value)); } //+------------------------------------------------------------------+ //| Serialize a long integer to its string representation | //+------------------------------------------------------------------+ string CPersistentSerializer::SerializeLong(const long value) { return(IntegerToString(value)); }
Оба метода вызывают функцию IntegerToString(), которая принимает параметр типа long — MQL5 автоматически преобразует int в long. Они определены как отдельные методы, а не как одна перегруженная функция, чтобы соответствовать типизированному API CPersistentStore, где SetInt() и SetLong() — отдельные вызовы с разным назначением. Разделение этих методов также избавляет разработчика от необходимости гадать, даст ли передача значения типа int в сериализатор для типа long другой результат.
//+------------------------------------------------------------------+ //| Serialize a double to its string representation | //+------------------------------------------------------------------+ string CPersistentSerializer::SerializeDouble(const double value, const int digits = 8) { return(DoubleToString(value, digits)); }
По умолчанию параметр digits равен 8, что обеспечивает достаточную точность для большинства торговых значений и позволяет избежать избыточного количества знаков после запятой, которые функция DoubleToString() выдавала бы при большем значении этого параметра. Что еще важнее, этот формат исключает экспоненциальную запись: вызов DoubleToString(0.0000001, 8) возвращает "0.00000010", и метод InferType() класса CPersistentValue при повторной загрузке правильно определяет тип этого значения как double. Значение в экспоненциальной записи, например "1e-7", вместо этого было бы классифицировано как строка.
//+------------------------------------------------------------------+ //| Serialize a boolean to its string representation | //+------------------------------------------------------------------+ string CPersistentSerializer::SerializeBool(const bool value) { return(value ? "true" : "false"); }
Строковые литералы "true" и "false" — единственные значения, которые метод InferType() классифицирует как булевы. Использование вместо них "1" и "0" привело бы к тому, что при повторной загрузке значение было бы классифицировано как целое число, и исходный тип не сохранился бы при преобразовании туда и обратно. Использование строчных букв также позволяет методу AsBool() сравнивать значение с "true" без дополнительной проверки регистра.
//+------------------------------------------------------------------+ //| Serialize a datetime to its string representation | //+------------------------------------------------------------------+ string CPersistentSerializer::SerializeDatetime(const datetime value) { return(TimeToString(value, TIME_DATE | TIME_MINUTES)); }
Сочетание флагов TIME_DATE | TIME_MINUTES задает формат ГГГГ.ММ.ДД ЧЧ:ММ длиной ровно 16 символов. Именно эту длину метод InferType() проверяет в первую очередь, определяя, может ли значение иметь тип datetime. Если бы был добавлен флаг TIME_SECONDS, результат содержал бы 19 символов, и метод InferType() не распознал бы тип datetime, классифицировав значение как строку.
//+------------------------------------------------------------------+ //| Serialize a plain string — passthrough | //+------------------------------------------------------------------+ string CPersistentSerializer::SerializeString(const string value) { return(value); }
Простое строковое значение не требует преобразования. Этот метод возвращает значение без изменений, чтобы обработка всех шести типов в CPersistentStore следовала одному шаблону вызова — Set(key, CPersistentSerializer::Serialize*(value)) — и не требовала отдельной обработки строк.
//+------------------------------------------------------------------+ //| Format a single key=value line with a line ending | //+------------------------------------------------------------------+ string CPersistentSerializer::FormatLine(const string key, const string value) { return(key + "=" + value + "\n"); }
Метод FormatLine() формирует одну полную строку плоского файла. Вместо \r\n используется символ новой строки Unix \n, чтобы файл можно было читать как в системах Windows, так и в системах Unix без необходимости преобразования. Метод TrimWhitespace() класса CPersistentParser обрабатывает случай открытия файла с окончаниями строк \r\n: завершающий символ \r удаляется при разборе, поэтому формат файла остается совместимым независимо от используемого варианта окончания строк.
//+------------------------------------------------------------------+ //| Write all key-value pairs to an open file handle | //+------------------------------------------------------------------+ bool CPersistentSerializer::WriteLines(const int handle, const string &keys[], const string &values[], const int count) { if(handle == INVALID_HANDLE) return(false); for(int i = 0; i < count; i++) { string line = FormatLine(keys[i], values[i]); uint bytes = FileWriteString(handle, line); if(bytes == 0) { PrintFormat("[PersistentSerializer] Write failed for key [%s]", keys[i]); return(false); } } return(true); }
Функция WriteLines() принимает пару параллельных массивов и явно указанное количество элементов, а не определяет количество на основе ArraySize(). Это позволяет вызывающему коду передавать массивы размером больше необходимого и при этом точно контролировать, сколько записей будет записано. Проверка на INVALID_HANDLE в начале гарантирует, что при неудачном вызове FileOpen() в вызывающем коде операция записи не завершится незаметно с признаком успеха, не записав ни одного байта. Функция FileWriteString() возвращает количество записанных байтов. Нулевое значение означает сбой записи, при котором метод заносит в журнал ключ проблемной записи и немедленно возвращает false, прерывая дальнейшую запись.
PersistentCache.mqh
Класс CPersistentCache служит оберткой над CHashMap<string,string> из пакета Generic стандартной библиотеки MQL5 и дополнительно поддерживает счетчик записей. Это слой системы, работающий в оперативной памяти. Он обеспечивает быстрый доступ со сложностью O(1) для всех операций чтения и записи и ничего не знает о диске.
//+------------------------------------------------------------------+ //| PersistentCache.mqh | //| Lightweight Persistent Key-Value Storage Engine | //+------------------------------------------------------------------+ #ifndef PERSISTENT_CACHE_MQH #define PERSISTENT_CACHE_MQH //--- Includes #include <Generic\HashMap.mqh> //+------------------------------------------------------------------+ //| CPersistentCache | //| Wraps CHashMap<string,string> to provide an in-memory key-value | //| store with O(1) average-case Get, Set, Delete, and Exists | //| operations. All persistence is delegated to CPersistentStore. | //+------------------------------------------------------------------+ class CPersistentCache { private: CHashMap<string,string> m_map; // Backing hash map int m_count; // Number of entries currently held public: CPersistentCache(void); ~CPersistentCache(void); bool Set(const string key, const string value); bool Get(const string key, string &out_value); bool Delete(const string key); bool Exists(const string key); int Count(void) const; void Clear(void); bool GetAllPairs(string &out_keys[], string &out_values[]); };
m_map обеспечивает хранение на основе хеш-таблицы. Счетчик m_count поддерживается отдельно, поскольку CHashMap не предоставляет прямого метода получения количества элементов с нужной здесь семантикой: самостоятельное отслеживание этого значения позволяет не обходить всю хеш-таблицу лишь для того, чтобы узнать число записей в ней.
//+------------------------------------------------------------------+ //| Constructor | //+------------------------------------------------------------------+ CPersistentCache::CPersistentCache(void) { m_count = 0; } //+------------------------------------------------------------------+ //| Destructor | //+------------------------------------------------------------------+ CPersistentCache::~CPersistentCache(void) { m_map.Clear(); }
Конструктор инициализирует переменную m_count, присваивая ей значение нуль. Деструктор явно вызывает m_map.Clear(), чтобы освободить всю память, выделенную в куче внутри CHashMap, до уничтожения объекта. Без этого вызова внутренние массивы корзин хеш-таблицы всё равно были бы освобождены средой выполнения, однако явная очистка делает намерение очевидным и обеспечивает своевременное освобождение памяти без опоры на неявный порядок уничтожения объектов.
//+------------------------------------------------------------------+ //| Insert or overwrite a key-value pair in the cache | //+------------------------------------------------------------------+ bool CPersistentCache::Set(const string key, const string value) { if(StringLen(key) == 0) return(false); //--- Probe for existing entry to distinguish insert from update string existing; bool already_present = m_map.TryGetValue(key, existing); if(!m_map.TrySetValue(key, value)) return(false); //--- Increment count only for new insertions if(!already_present) m_count++; return(true); }
Функция Set() должна различать вставку нового ключа и обновление существующего, поскольку только при вставке следует увеличивать значение m_count. Проверка с помощью TryGetValue() перед вызовом TrySetValue() и является механизмом, обеспечивающим это различие. Без этой проверки повторные вызовы Set() с одним и тем же ключом увеличивали бы m_count при каждом вызове, и значение счетчика всё больше превышало бы фактическое число записей. В результате GetAllPairs() выделял бы массивы избыточного размера, что могло бы привести к записи мусорных данных в файл.
//+------------------------------------------------------------------+ //| Retrieve a value by key; returns false if key is absent | //+------------------------------------------------------------------+ bool CPersistentCache::Get(const string key, string &out_value) { return(m_map.TryGetValue(key, out_value)); }
Метод Get() напрямую делегирует вызов методу TryGetValue(), который возвращает значение true и заполняет переменную out_value, если ключ существует, либо возвращает значение false и оставляет переменную out_value без изменений, если ключ отсутствует. Сложность метода в среднем составляет O(1): хеш-таблица вычисляет индекс корзины по строке ключа и выполняет прямой поиск без полного перебора.
//+------------------------------------------------------------------+ //| Remove a key-value pair from the cache | //+------------------------------------------------------------------+ bool CPersistentCache::Delete(const string key) { if(!m_map.Remove(key)) return(false); m_count--; return(true); }
Метод Delete() вызывает метод Remove() хеш-таблицы, который возвращает false, если ключ отсутствовал. В этом случае метод возвращает значение false, не изменяя m_count. Значение m_count уменьшается на единицу только в случае успешного выполнения Remove(), подтверждающего, что запись действительно существовала и была удалена. Благодаря этому число записей остаётся точным независимо от того, проверяет ли вызывающий код наличие ключа перед вызовом Delete().
//+------------------------------------------------------------------+ //| Return true if the key exists in the cache | //+------------------------------------------------------------------+ bool CPersistentCache::Exists(const string key) { string dummy; return(m_map.TryGetValue(key, dummy)); }
Метод Exists() использует TryGetValue() с выходным параметром, значение которого затем не используется, вместо добавления в CHashMap отдельного метода ContainsKey(). Вспомогательная переменная получает значение, если ключ существует, но её содержимое никогда не считывается. Это осознанное проектное решение, позволяющее избежать дополнительного обхода хеш-таблицы, который потребовался бы при отдельной проверке наличия ключа, и сохранить минимальный набор методов API класса CPersistentCache.
//+------------------------------------------------------------------+ //| Return the number of entries currently held in the cache | //+------------------------------------------------------------------+ int CPersistentCache::Count(void) const { return(m_count); } //+------------------------------------------------------------------+ //| Remove all entries from the cache | //+------------------------------------------------------------------+ void CPersistentCache::Clear(void) { m_map.Clear(); m_count = 0; }
Метод Count() возвращает значение m_count напрямую, без обращения к хеш-таблице. Метод Clear() вызывает m_map.Clear() для удаления всех записей и обнуляет m_count. Обе операции имеют сложность O(1) по отношению к количеству записей.
//+------------------------------------------------------------------+ //| Export all keys and values to parallel arrays for serialization | //+------------------------------------------------------------------+ bool CPersistentCache::GetAllPairs(string &out_keys[], string &out_values[]) { if(m_count == 0) { ArrayResize(out_keys, 0); ArrayResize(out_values, 0); return(false); } //--- Resize both arrays to the current entry count ArrayResize(out_keys, m_count); ArrayResize(out_values, m_count); //--- Copy all keys and values in a single call starting at index 0 m_map.CopyTo(out_keys, out_values, 0); return(true); }
Метод GetAllPairs() вызывается методом CPersistentStore::SaveFile() для получения всего содержимого кэша для записи на диск. Перед вызовом CopyTo() размер обоих выходных массивов необходимо установить равным m_count, поскольку CHashMap::CopyTo() записывает данные в заранее выделенную память, не изменяя размеры целевых массивов. Перегрузка метода CopyTo(out_keys, out_values, 0) с тремя аргументами заполняет оба массива за один проход, при этом третий аргумент указывает начальный индекс в массиве назначения. Порядок обхода зависит от хеширования и может меняться от запуска к запуску. Это допустимо, поскольку формат плоского файла не требует определённого порядка ключей.
PersistentStore.mqh
CPersistentStore — это модуль, с которым код торгового советника (EA) взаимодействует напрямую. Он содержит экземпляр CPersistentCache для доступа к данным в оперативной памяти и строку с именем файла для доступа к данным на диске, а также координирует работу с ними. Именно здесь реализуется политика сквозной записи: каждое изменение данных сначала применяется к кэшу, а затем немедленно вызывает полную перезапись файла.
//+------------------------------------------------------------------+ //| PersistentStore.mqh | //| Lightweight Persistent Key-Value Storage Engine | //+------------------------------------------------------------------+ #ifndef PERSISTENT_STORE_MQH #define PERSISTENT_STORE_MQH //--- Includes #include "PersistentValue.mqh" #include "PersistentParser.mqh" #include "PersistentSerializer.mqh" #include "PersistentCache.mqh" //+------------------------------------------------------------------+ //| CPersistentStore | //| Unified interface for persistent key-value storage. | //| Manages the lifecycle of the backing flat file and the in-memory | //| cache. Provides Get, Set, Delete, Exists, and typed accessors. | //+------------------------------------------------------------------+ class CPersistentStore { private: CPersistentCache m_cache; // In-memory hash map cache string m_file_name; // Backing flat file name bool m_initialized; // True after successful Init() bool LoadFile(void); bool SaveFile(void); public: CPersistentStore(void); ~CPersistentStore(void); bool Init(const string file_name); void Deinit(void); bool Set(const string key, const string value); bool SetInt(const string key, const int value); bool SetLong(const string key, const long value); bool SetDouble(const string key, const double value, const int digits = 8); bool SetBool(const string key, const bool value); bool SetDatetime(const string key, const datetime value); bool Get(const string key, string &out_value); bool GetInt(const string key, int &out_value); bool GetLong(const string key, long &out_value); bool GetDouble(const string key, double &out_value); bool GetBool(const string key, bool &out_value); bool GetDatetime(const string key, datetime &out_value); bool Delete(const string key); bool Exists(const string key); int Count(void) const; };
Флаг m_initialized служит основным защитным механизмом, который предотвращает выполнение любых операций до успешного завершения Init(). Каждый общедоступный метод проверяет этот флаг перед тем, как продолжить работу. Методы LoadFile() и SaveFile() являются закрытыми (private), поскольку внешний код не должен напрямую инициировать файловые операции: всё взаимодействие с диском управляется внутри класса в ответ на вызовы общедоступного API.
//+------------------------------------------------------------------+ //| Constructor | //+------------------------------------------------------------------+ CPersistentStore::CPersistentStore(void) { m_file_name = ""; m_initialized = false; } //+------------------------------------------------------------------+ //| Destructor | //+------------------------------------------------------------------+ CPersistentStore::~CPersistentStore(void) { }
Конструктор устанавливает безопасное неинициализированное состояние. При любом вызове функций Set(), Get(), Delete() или Exists() до вызова Init() переменная m_initialized будет иметь значение false, и вызов завершится немедленно, без обращения к памяти или диску. Деструктор пустой: m_cache уничтожается собственным деструктором, а память строки m_file_name освобождается средствами среды выполнения MQL5.
//+------------------------------------------------------------------+ //| Initialize the store with a backing file name | //+------------------------------------------------------------------+ bool CPersistentStore::Init(const string file_name) { m_file_name = file_name; m_initialized = false; if(StringLen(m_file_name) == 0) { Print("[PersistentStore] Init failed: empty file name"); return(false); } //--- Load existing entries from disk if the file exists if(FileIsExist(m_file_name)) { if(!LoadFile()) { PrintFormat("[PersistentStore] Init failed: could not load [%s]", m_file_name); return(false); } } else { PrintFormat("[PersistentStore] New store: [%s] does not exist yet", m_file_name); } m_initialized = true; PrintFormat("[PersistentStore] Initialized — file: %s | Entries: %d", m_file_name, m_cache.Count()); return(true); }
Функция Init() обрабатывает два сценария с помощью одной и той же последовательности кода. Если файл хранилища существует, вызывается функция LoadFile() для заполнения кэша данными с диска. Если файл отсутствует (то есть при первом запуске), кэш остаётся пустым, и файл будет создан при первом вызове Set(). Переменная m_initialized принимает значение true только после успешного прохождения всех проверок. Если вызов LoadFile() завершится с ошибкой, m_initialized останется равным false, и все последующие вызовы API будут безопасно возвращать значение false. В журнал выводятся имя файла и количество восстановленных записей, что позволяет разработчику сразу определить, удалось ли восстановить состояние предыдущего сеанса.
//+------------------------------------------------------------------+ //| Release resources and clear the cache | //+------------------------------------------------------------------+ void CPersistentStore::Deinit(void) { m_cache.Clear(); m_initialized = false; Print("[PersistentStore] Deinitialized"); }
Функция Deinit() очищает кэш в оперативной памяти и сбрасывает флаг инициализации. Она не записывает данные на диск. При сквозной записи файл всегда содержит актуальные данные, поэтому при завершении работы сброс данных на диск не требуется. Основная цель функции Deinit() — своевременно освободить память CHashMap и вернуть объект в безопасное неинициализированное состояние, чтобы при повторном подключении советника к графику без перезапуска терминала последующий вызов Init() начинал работу с чистого исходного состояния.
//+------------------------------------------------------------------+ //| Load key-value pairs from the flat file into the cache | //+------------------------------------------------------------------+ bool CPersistentStore::LoadFile(void) { m_cache.Clear(); //--- Open file for reading in text mode int handle = FileOpen(m_file_name, FILE_READ | FILE_TXT | FILE_ANSI); if(handle == INVALID_HANDLE) { PrintFormat("[PersistentStore] LoadFile: cannot open [%s] error=%d", m_file_name, GetLastError()); return(false); } int loaded = 0; //--- Read each line until end of file while(!FileIsEnding(handle)) { string line = FileReadString(handle); string key, value; if(!CPersistentParser::ParseLine(line, key, value)) continue; // Skip invalid lines silently m_cache.Set(key, value); loaded++; } FileClose(handle); PrintFormat("[PersistentStore] Loaded %d entries from [%s]", loaded, m_file_name); return(true); }
Функция LoadFile() очищает кэш перед чтением, чтобы при повторном вызове (например, после перезагрузки графика) не накапливались дубликаты записей. Файл открывается с параметрами FILE_READ | FILE_TXT | FILE_ANSI. Флаг FILE_TXT включает построчное чтение с помощью функции FileReadString(), которая возвращает одну полную строку за каждый вызов. Флаг FILE_ANSI обеспечивает использование единой однобайтовой кодировки символов. Если ParseLine() не удаётся обработать строку, она пропускается без каких-либо сообщений. Это касается пустых строк, комментариев и записей с некорректным форматом. Такой подход позволяет сохранить работоспособность при частичном повреждении файла: загружаются доступные корректные записи, а не прерывается вся загрузка. Дескриптор файла закрывается безусловно после завершения цикла, независимо от того, возникли ли ошибки при разборе.
//+------------------------------------------------------------------+ //| Rewrite the flat file from the current cache contents | //+------------------------------------------------------------------+ bool CPersistentStore::SaveFile(void) { if(!m_initialized) return(false); //--- Retrieve all cache entries as parallel arrays string keys[]; string values[]; m_cache.GetAllPairs(keys, values); int count = m_cache.Count(); //--- Open file for writing, truncating any existing content int handle = FileOpen(m_file_name, FILE_WRITE | FILE_TXT | FILE_ANSI); if(handle == INVALID_HANDLE) { PrintFormat("[PersistentStore] SaveFile: cannot open [%s] error=%d", m_file_name, GetLastError()); return(false); } //--- Write all entries bool ok = CPersistentSerializer::WriteLines(handle, keys, values, count); FileClose(handle); if(ok) PrintFormat("[PersistentStore] Saved %d entries to [%s]", count, m_file_name); return(ok); }
Функция SaveFile() вызывается функциями Set() и Delete() после каждого изменения данных. Она экспортирует весь кэш в параллельные массивы с помощью GetAllPairs(), открывает файл с флагами FILE_WRITE | FILE_TXT | FILE_ANSI, что приводит к усечению существующего файла до нулевого размера перед записью, и передаёт форматирование и запись строк функции CPersistentSerializer::WriteLines(). Дескриптор файла закрывается до того, как функция SaveFile() завершит работу, поэтому никакая внешняя программа, считывающая файл сразу после вызова Set(), не сможет обнаружить состояние, при котором файл записан не полностью. Основной сценарий отказа связан с усечением файла с последующей записью. Если процесс остановится после усечения файла, но до завершения записи, при следующей загрузке файл может оказаться пустым или неполным.
//+------------------------------------------------------------------+ //| Store a string value; update cache and rewrite file | //+------------------------------------------------------------------+ bool CPersistentStore::Set(const string key, const string value) { if(!m_initialized) return(false); if(!m_cache.Set(key, value)) return(false); PrintFormat("[PersistentStore] Set [%s] = %s", key, value); return(SaveFile()); } //+------------------------------------------------------------------+ //| Store an integer value | //+------------------------------------------------------------------+ bool CPersistentStore::SetInt(const string key, const int value) { return(Set(key, CPersistentSerializer::SerializeInt(value))); } //+------------------------------------------------------------------+ //| Store a long integer value | //+------------------------------------------------------------------+ bool CPersistentStore::SetLong(const string key, const long value) { return(Set(key, CPersistentSerializer::SerializeLong(value))); } //+------------------------------------------------------------------+ //| Store a double value | //+------------------------------------------------------------------+ bool CPersistentStore::SetDouble(const string key, const double value, const int digits = 8) { return(Set(key, CPersistentSerializer::SerializeDouble(value, digits))); } //+------------------------------------------------------------------+ //| Store a boolean value | //+------------------------------------------------------------------+ bool CPersistentStore::SetBool(const string key, const bool value) { return(Set(key, CPersistentSerializer::SerializeBool(value))); } //+------------------------------------------------------------------+ //| Store a datetime value | //+------------------------------------------------------------------+ bool CPersistentStore::SetDatetime(const string key, const datetime value) { return(Set(key, CPersistentSerializer::SerializeDatetime(value))); }
Set() — это основной метод записи. Сначала он обновляет кэш и вызывает SaveFile() только в том случае, если обновление кэша прошло успешно. Такой порядок действий выбран намеренно: если обновление кэша завершится неудачей, например из-за пустого ключа, ненужная попытка записи на диск не предпринимается. Типизированные методы Set*() выполняют вспомогательную функцию: преобразуют переданное значение в строковый формат и передают его методу Set(). Они позволяют в коде советника писать ExtStore.SetInt("trade_counter", 42) вместо ExtStore.Set("trade_counter", IntegerToString(42)), сохраняя читаемость вызывающего кода и избавляя от необходимости вручную выполнять сериализацию.
//+------------------------------------------------------------------+ //| Retrieve a raw string value by key | //+------------------------------------------------------------------+ bool CPersistentStore::Get(const string key, string &out_value) { if(!m_initialized) return(false); bool found = m_cache.Get(key, out_value); if(found) PrintFormat("[PersistentStore] Get [%s] = %s", key, out_value); else PrintFormat("[PersistentStore] Get [%s] — key not found", key); return(found); } //+------------------------------------------------------------------+ //| Retrieve an integer value by key | //+------------------------------------------------------------------+ bool CPersistentStore::GetInt(const string key, int &out_value) { string raw; if(!Get(key, raw)) return(false); CPersistentValue pv; pv.FromString(raw); out_value = pv.AsInt(); return(true); } //+------------------------------------------------------------------+ //| Retrieve a long integer value by key | //+------------------------------------------------------------------+ bool CPersistentStore::GetLong(const string key, long &out_value) { string raw; if(!Get(key, raw)) return(false); CPersistentValue pv; pv.FromString(raw); out_value = pv.AsLong(); return(true); } //+------------------------------------------------------------------+ //| Retrieve a double value by key | //+------------------------------------------------------------------+ bool CPersistentStore::GetDouble(const string key, double &out_value) { string raw; if(!Get(key, raw)) return(false); CPersistentValue pv; pv.FromString(raw); out_value = pv.AsDouble(); return(true); } //+------------------------------------------------------------------+ //| Retrieve a boolean value by key | //+------------------------------------------------------------------+ bool CPersistentStore::GetBool(const string key, bool &out_value) { string raw; if(!Get(key, raw)) return(false); CPersistentValue pv; pv.FromString(raw); out_value = pv.AsBool(); return(true); } //+------------------------------------------------------------------+ //| Retrieve a datetime value by key | //+------------------------------------------------------------------+ bool CPersistentStore::GetDatetime(const string key, datetime &out_value) { string raw; if(!Get(key, raw)) return(false); CPersistentValue pv; pv.FromString(raw); out_value = pv.AsDatetime(); return(true); }
Метод Get() извлекает исходную строку из кэша и записывает результат в журнал независимо от того, найден ли ключ. Отсутствие ключа намеренно фиксируется в журнале: во время отладки причину отсутствия результата без сообщения установить сложнее, чем при наличии явного сообщения "ключ не найден". Типизированные методы Get*() следуют единому шаблону: извлекают исходную строку, создают объект CPersistentValue в стеке, вызывают метод FromString() для его заполнения и классификации, а затем вызывают соответствующий типизированный метод доступа. Экземпляр CPersistentValue является локальной переменной, размещённой в стеке, и уничтожается при выходе из метода, поэтому не требует дополнительной памяти, сохраняемой между вызовами.
//+------------------------------------------------------------------+ //| Remove a key-value pair; update cache and rewrite file | //+------------------------------------------------------------------+ bool CPersistentStore::Delete(const string key) { if(!m_initialized) return(false); if(!m_cache.Delete(key)) { PrintFormat("[PersistentStore] Delete [%s] — key not found", key); return(false); } PrintFormat("[PersistentStore] Deleted [%s]", key); return(SaveFile()); }
Метод Delete() удаляет ключ из кэша и в случае успеха запускает полную перезапись файла с помощью SaveFile(). Если ключ отсутствует в кэше, метод возвращает значение false, не затрагивая файл. В формате плоского файла перезапись файла после удаления неизбежна — не существует механизма, позволяющего удалить одну строку из середины последовательного файла без его полной перезаписи.
//+------------------------------------------------------------------+ //| Return true if the key exists in the store | //+------------------------------------------------------------------+ bool CPersistentStore::Exists(const string key) { if(!m_initialized) return(false); return(m_cache.Exists(key)); } //+------------------------------------------------------------------+ //| Return the number of entries currently in the store | //+------------------------------------------------------------------+ int CPersistentStore::Count(void) const { return(m_cache.Count()); }
Метод Exists() передаёт проверку кэшу и не обращается к диску. Метод Count() напрямую возвращает количество записей в кэше. Обе операции имеют сложность O(1). Если отсутствие ключа допустимо, перед вызовом Get() рекомендуется проверять его наличие с помощью Exists(): это позволяет избежать лишних сообщений "ключ не найден", которые Get() записывает в журнал.
PersistentStoreDemo.mq5
//+------------------------------------------------------------------+ //| PersistentStoreDemo.mq5 | //| Lightweight Persistent Key-Value Storage Engine | //+------------------------------------------------------------------+ //--- Includes #include <Persistent_Key_Value_Store/PersistentStore.mqh> //--- Inputs input string InpFileName = "EAState.kv"; // Backing store file name input bool InpClearOnStart = false; // Delete file before loading //--- Global Variables CPersistentStore ExtStore; // Global persistent store instance
ExtStore объявлен в глобальной области видимости, чтобы все обработчики событий советника — OnInit(), OnDeinit(), OnTick() — использовали один и тот же экземпляр хранилища без необходимости передавать его в качестве параметра. Параметр InpFileName позволяет пользователю задать имя файла хранилища в диалоговом окне свойств советника. InpClearOnStart предоставляет удобный механизм сброса для тестирования: установка этого параметра в значение true приводит к удалению файла перед загрузкой, что имитирует первый запуск с "чистого листа" без необходимости ручного удаления файла пользователем.
//+------------------------------------------------------------------+ //| Print a section separator to the Experts log | //+------------------------------------------------------------------+ void PrintSeparator(const string label) { PrintFormat("--- %s ---", label); }
Небольшая служебная функция, которая выводит на вкладку "Эксперты" разделительную линию с подписью. Благодаря этому поэтапный вывод результатов демонстрации становится удобным для визуального восприятия — каждый раздел журнала четко разграничен. Читатель может легко определить, какая часть демонстрации привела к появлению той или иной группы сообщений.
//+------------------------------------------------------------------+ //| Write initial EA state to the store | //+------------------------------------------------------------------+ void WriteInitialState(void) { PrintSeparator("Writing Initial State"); //--- Store a boolean flag indicating the daily target has been reached ExtStore.SetBool("daily_target_hit", true); //--- Store an integer trade counter ExtStore.SetInt("trade_counter", 42); //--- Store the timestamp of the last optimization run ExtStore.SetDatetime("last_optimization", StringToTime("2025.05.01 09:00")); //--- Store the current risk mode as a plain string ExtStore.Set("risk_mode", "conservative"); //--- Store a floating-point drawdown threshold ExtStore.SetDouble("max_drawdown_pct", 4.50, 2); //--- Store a long-form account identifier ExtStore.SetLong("account_id", 100234567890); }
Эта функция записывает шесть значений, охватывающих все поддерживаемые типы: логический тип (bool), целочисленный (int), дата и время (datetime), строка (string), число с плавающей точкой (double) и длинное целое (long). Каждый вызов задействует отдельный типизированный метод Set*(). После возврата из функции WriteInitialState() плоский файл на диске содержит все шесть записей, и при каждом последующем запуске советника они будут доступны для восстановления.
//+------------------------------------------------------------------+ //| Read back and verify all stored values | //+------------------------------------------------------------------+ void ReadAndVerifyState(void) { PrintSeparator("Reading Stored State"); //--- Retrieve and display boolean value bool daily_hit = false; if(ExtStore.GetBool("daily_target_hit", daily_hit)) PrintFormat("daily_target_hit = %s", daily_hit ? "true" : "false"); else Print("daily_target_hit — NOT FOUND"); //--- Retrieve and display integer counter int trade_cnt = 0; if(ExtStore.GetInt("trade_counter", trade_cnt)) PrintFormat("trade_counter = %d", trade_cnt); else Print("trade_counter — NOT FOUND"); //--- Retrieve and display datetime datetime last_opt = 0; if(ExtStore.GetDatetime("last_optimization", last_opt)) PrintFormat("last_optimization = %s", TimeToString(last_opt, TIME_DATE | TIME_MINUTES)); else Print("last_optimization — NOT FOUND"); //--- Retrieve and display string value string risk_mode = ""; if(ExtStore.Get("risk_mode", risk_mode)) PrintFormat("risk_mode = %s", risk_mode); else Print("risk_mode — NOT FOUND"); //--- Retrieve and display double value double dd_pct = 0.0; if(ExtStore.GetDouble("max_drawdown_pct", dd_pct)) PrintFormat("max_drawdown_pct = %.2f", dd_pct); else Print("max_drawdown_pct — NOT FOUND"); //--- Retrieve and display long integer long acct_id = 0; if(ExtStore.GetLong("account_id", acct_id)) PrintFormat("account_id = %I64d", acct_id); else Print("account_id — NOT FOUND"); }
Функция ReadAndVerifyState() вызывается как после первоначальной записи, так и после имитации перезапуска. При первом запуске она проверяет, что каждый вызов Set*() сохранил именно те данные, которые предполагалось сохранить. При последующих запусках она проверяет, что значения сохранились после перезапуска терминала и были правильно восстановлены из плоского файла. При каждом чтении используется локальная переменная, инициализированная известным значением по умолчанию, чтобы результат NOT FOUND был однозначным. Если бы проверка была пропущена, выводилось бы значение по умолчанию, а не предыдущее значение.
//+------------------------------------------------------------------+ //| Simulate state changes that occur mid-session | //+------------------------------------------------------------------+ void SimulateMidSessionUpdate(void) { PrintSeparator("Simulating Mid-Session Update"); //--- Read current trade counter, increment, and write back int trade_cnt = 0; if(ExtStore.GetInt("trade_counter", trade_cnt)) { trade_cnt += 3; ExtStore.SetInt("trade_counter", trade_cnt); PrintFormat("trade_counter updated to %d", trade_cnt); } //--- Switch risk mode to aggressive ExtStore.Set("risk_mode", "aggressive"); Print("risk_mode switched to aggressive"); //--- Add a new key that did not exist in the initial state ExtStore.Set("session_id", "NY_20250501"); Print("session_id added"); }
Эта функция демонстрирует схему "чтение — изменение — запись", типичную для практического использования советника. Значение счетчика сделок считывается из хранилища, увеличивается в локальной переменной и записывается обратно. Каждый вызов Set*() приводит к полной перезаписи файла, но файл при этом остаётся согласованным — если бы терминал был закрыт после любой отдельной записи, файл содержал бы все изменения данных, внесённые до этого момента. Добавление `session_id` показывает, что новые ключи можно добавлять в любой момент без какого-либо объявления схемы.
//+------------------------------------------------------------------+ //| Demonstrate key existence check and deletion | //+------------------------------------------------------------------+ void DemonstrateDeletion(void) { PrintSeparator("Demonstrating Deletion"); //--- Verify key exists before deletion if(ExtStore.Exists("max_drawdown_pct")) Print("max_drawdown_pct EXISTS — proceeding to delete"); else Print("max_drawdown_pct NOT FOUND"); //--- Delete the key if(ExtStore.Delete("max_drawdown_pct")) Print("max_drawdown_pct deleted successfully"); else Print("max_drawdown_pct deletion failed"); //--- Confirm key is no longer present if(!ExtStore.Exists("max_drawdown_pct")) Print("Confirmed: max_drawdown_pct is no longer in store"); }
Эта функция демонстрирует рекомендуемый алгоритм удаления: сначала выполнить проверку с помощью Exists(), затем вызвать Delete(), а после этого провести повторную проверку с помощью Exists(). После возврата из этой функции в плоском файле на диске больше не будет записи max_drawdown_pct. При следующем запуске советника функция LoadFile() не найдет записи с этим ключом.
//+------------------------------------------------------------------+ //| Print a summary of the final store state | //+------------------------------------------------------------------+ void PrintFinalState(void) { PrintSeparator("Final Store State"); PrintFormat("Total entries in store: %d", ExtStore.Count()); ReadAndVerifyState(); }
Функция PrintFinalState() выводит в журнал количество записей и повторно считывает все значения, чтобы дать разработчику полное представление о том, что в данный момент находится на диске. Именно это состояние будет восстановлено при следующем запуске советника, что позволит легко убедиться в том, что демонстрация дала ожидаемый результат, сохраняющийся между запусками.
//+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit(void) { PrintSeparator("PersistentStoreDemo OnInit"); //--- Optionally delete the backing file to simulate a clean first run if(InpClearOnStart && FileIsExist(InpFileName)) { FileDelete(InpFileName); PrintFormat("Cleared existing file: %s", InpFileName); } //--- Initialize the persistent store if(!ExtStore.Init(InpFileName)) { Print("[Demo] Store initialization failed"); return(INIT_FAILED); } //--- If no existing state was found, write the initial values if(ExtStore.Count() == 0) { Print("[Demo] No existing state found — writing initial values"); WriteInitialState(); } else { PrintFormat("[Demo] Restored %d entries from previous session", ExtStore.Count()); } //--- Read and verify the current state ReadAndVerifyState(); //--- Simulate changes that would occur during a live session SimulateMidSessionUpdate(); //--- Demonstrate deletion DemonstrateDeletion(); //--- Print the final state as it will persist to the next restart PrintFinalState(); return(INIT_SUCCEEDED); }
OnInit() — это точка входа, которая координирует выполнение всех этапов демонстрации. Ветвь InpClearOnStart удаляет файл до вызова Init(), что позволяет имитировать первый запуск без ручного удаления файлов. Ветвь "Count() == 0" после инициализации обрабатывает как случай первого запуска, так и случай "чистого" запуска. Если записи не были загружены, записывается начальное состояние. Если записи были загружены, указывается их количество, что подтверждает восстановление состояния предыдущего сеанса. Остальные вызовы выполняют все этапы демонстрации в логическом порядке.
//+------------------------------------------------------------------+ //| Expert deinitialization function | //+------------------------------------------------------------------+ void OnDeinit(const int reason) { PrintSeparator("PersistentStoreDemo OnDeinit"); PrintFormat("[Demo] Deinit reason: %d | Entries: %d", reason, ExtStore.Count()); ExtStore.Deinit(); } //+------------------------------------------------------------------+ //| Expert tick function | //+------------------------------------------------------------------+ void OnTick(void) { //--- State management occurs in OnInit and OnDeinit //--- No tick-level logic is required for this demonstration }
Функция OnDeinit() записывает в журнал код причины деинициализации и текущее количество записей, а затем вызывает метод ExtStore.Deinit() для освобождения кэша. Код причины полезен на этапе разработки — значение 0 означает, что советник был удален вручную, а другие коды указывают на закрытие графика, перекомпиляцию или завершение работы терминала. В данной демонстрации функция OnTick() не содержит кода, поскольку управление состоянием осуществляется исключительно в функциях OnInit() и OnDeinit(). В советнике, используемом в реальных условиях, функция OnTick() в ходе обычной работы вызывала бы методы Set*() или Get*() по мере необходимости.

Файл.kv, открытый в текстовом редакторе. Видны записи daily_target_hit, trade_counter, last_optimization и risk_mode.
Определение типа
В плоском файле все значения хранятся в виде строк. Когда советник извлекает значение, он должен интерпретировать эту строку как значение нужного типа. API CPersistentStore предоставляет типизированные методы доступа, которые выполняют это преобразование.
Таблица определения типа:
| Тип | Пример сохраненной строки | Правило определения типа |
|---|---|---|
| bool | true или false | Совпадение с "true" или "false" с учетом регистра |
| int | 42 | Преобразование туда и обратно с помощью StringToInteger() проходит успешно, без необработанных символов в конце строки |
| long | 1234567890123 | То же, что и int, — различаются по величине значения |
| double | 1.23450 | Функция StringToDouble() выполняется успешно; наличие символа "." служит отличительным признаком |
| datetime | 2025.05.01 10:00 | Соответствие шаблону YYYY.MM.DD HH:MM |
| строка | conservative | По умолчанию — ни одно другое правило не подошло |
Хранилище не выполняет автоматическое определение типа при загрузке. Кэш хранит все значения в виде строк, а преобразование происходит, когда вызывающий код использует типизированную перегрузку Get(). Это позволяет избежать неоднозначности в тех случаях, когда значение, например "1", может интерпретироваться как целое число, логическое значение или строка в зависимости от контекста.
Кэш со сквозной записью
Кэш реализован с использованием CHashMap<string,string>, обеспечивающей среднюю вычислительную сложность вставки и поиска O(1). После первоначальной загрузки файла все вызовы функций Get() и Exists() полностью обслуживаются из кэша. При операциях чтения обращения к диску не выполняются.
Политика сквозной записи предусматривает, что каждое изменение данных — каждый вызов Set() или Delete() — атомарно обновляет и кэш, и файл хранилища в рамках одного вызова (но не на уровне файловой системы). Файл всегда содержит актуальную информацию. Если терминал неожиданно закрывается между двумя вызовами Set(), файл содержит все изменения данных вплоть до последнего завершенного вызова Set() включительно.
Архитектура со сквозной записью:
Set(key, value) ↓ Update CHashMap ↓ Rewrite flat file ↓ Disk synchronized
Производительность записи мог бы повысить альтернативный подход — кэширование с отложенной записью, при котором изменения данных накапливаются в памяти и периодически записываются на диск. Однако в этом случае возникает временной интервал, в течение которого состояние данных на диске не соответствует состоянию кэша. Сбой терминала в течение этого периода приводит к потере данных. В типичном сценарии управления состоянием советника количество изменений данных за сеанс настолько невелико, что затраты на сквозную запись несущественны.
Загрузка при запуске
При инициализации CPersistentStore последовательно считывает файл хранилища от начала до конца, добавляя в хеш-таблицу все корректные пары "ключ — значение". Недопустимые строки — пустые строки, строки без разделителя "=" или строки, начинающиеся с символа "#", — пропускаются без предупреждения. После полного разбора файла все последующие операции чтения выполняются из кэша.
Последовательность загрузки при запуске:
EA OnInit() ↓ CPersistentStore::Init(filename) ↓ FileOpen(filename, FILE_READ|FILE_TXT) ↓ Read each line ↓ Split at first '=' character ↓ Validate key and value ↓ CHashMap::Add(key, value) ↓ Cache ready — file handle closed
Дескриптор файла закрывается сразу после завершения загрузки. В ходе обычной работы хранилище не удерживает открытый дескриптор файла. Каждый вызов функции SaveFile() открывает файл, записывает в него содержимое и закрывает дескриптор файла перед возвратом. Это позволяет избежать утечек дескрипторов и устраняет конфликты, возникающие в случае попыток внешних инструментов прочитать файл.
Анализ надежности
Отсутствующие файлы: если при вызове функции Init() файл хранилища отсутствует, хранилище инициализируется с пустым кэшем. Первый вызов Set() создает файл. Это нормальное поведение при первом запуске.
Повреждённые файлы: строки, которые невозможно интерпретировать как пары "ключ — значение", игнорируются без вывода сообщений. Это позволяет сохранить работоспособность системы даже при повреждении части файла: в таком случае кэш будет заполнен лишь частично. Заведомо корректные записи сохраняются, а поврежденные отбрасываются. Если функция SaveFile() вызывается после загрузки повреждённого файла, повреждённые записи удаляются без возможности восстановления, поскольку перезапись осуществляется на основе содержимого кэша.
Неполная запись: функция SaveFile() открывает файл с флагами FILE_WRITE|FILE_TXT, что приводит к усечению файла до нулевой длины перед началом записи. Если терминал аварийно завершит работу после усечения файла, но до завершения записи, при следующем запуске файл будет пустым или неполным. Это основной тип отказа данной архитектуры. Меры по снижению риска включают запись во временный файл и его атомарное переименование — прием, который не поддерживается штатными средствами файлового API MQL5 и требует использования DLL. Для большинства сценариев использования EA вероятность такого сбоя и его последствия приемлемы.
Параллельный доступ: MQL5 не предоставляет средств блокировки файлов. Одновременная запись двух экземпляров советника в один и тот же файл приведет к его повреждению. Правильная архитектура предполагает использование одного экземпляра CPersistentStore на каждый файл в пределах терминала и отдельных имен файлов для каждого советника.
Сравнение с альтернативными вариантами
| Метод | Преимущества | Ограничения |
|---|---|---|
| Глобальные переменные терминала (GlobalVariables) | Встроены в терминал; не требуют файлового ввода-вывода | Общее пространство имен; только тип double; данные не представлены в человекочитаемом виде |
| Статические переменные | Нулевые накладные расходы | Не обеспечивают сохранение данных между запусками; значения теряются при любой перезагрузке программы |
| Массивы в оперативной памяти | Быстрый доступ; гибкая структура | Не обеспечивают сохранение данных между запусками |
| SQLite через DLL | Реляционные запросы; транзакции ACID | Внешняя зависимость; сложность развертывания |
| Плоский файл "ключ — значение" | Понятный текстовый формат; переносимость; отсутствие зависимостей | Последовательные обновления; отсутствие блокировок; отсутствие схемы |
Не существует единого подхода, который был бы правильным во всех случаях. Глобальные переменные терминала целесообразно использовать, когда необходимо сохранять только числовые значения и конфликты в пространстве имен не представляют проблемы. SQLite подходит в тех случаях, когда состояние имеет реляционную структуру или когда требуются гарантии ACID. Плоские файлы подходят в тех случаях, когда основными требованиями являются простота, переносимость и удобство отладки.
Ожидаемый вывод в журнал
После первого запуска и последующего перезапуска на вкладке "Эксперты" отобразится следующая информация:
[PersistentStore] Initialized — file: EAState.kv [PersistentStore] Loaded 0 entries (new file) [PersistentStore] Set [daily_target_hit] = true [PersistentStore] Set [trade_counter] = 42 [PersistentStore] Set [last_optimization] = 2025.05.01 09:00 [PersistentStore] Set [risk_mode] = conservative [PersistentStore] Saved 4 entries --- EA Restart --- [PersistentStore] Initialized — file: EAState.kv [PersistentStore] Loaded 4 entries [PersistentStore] Get [trade_counter] = 42 [PersistentStore] Get [daily_target_hit] = true [PersistentStore] Get [risk_mode] = conservative The file on disk during that session contains: daily_target_hit=true trade_counter=42 last_optimization=2025.05.01 09:00 risk_mode=conservative

Журнал на вкладке "Эксперты" терминала, демонстрирующий вызовы методов Set() и Get(), а также успешное восстановление значения после имитации перезапуска советника.
Заключение
Архитектура CPersistentStore решает фундаментальную проблему потери состояния советника за счет двухуровневой структуры: хеш-таблицы CHashMap в оперативной памяти, обеспечивающей доступ на чтение со сложностью O(1), и плоского файла, сохраняющего данные между перезапусками терминала. Политика кэша со сквозной записью устраняет риск расхождения между содержимым кэша и данными на диске ценой полной перезаписи файла при каждом изменении данных. Для типичного файла состояния советника, который имеет небольшой размер, редко изменяется и обрабатывается последовательно, эти затраты незначительны.
В данной реализации полностью исключены внешние зависимости. Для работы этой реализации не требуются ни DLL, ни движок базы данных, ни проприетарный двоичный формат. Файл хранилища имеет человекочитаемый формат, переносим и доступен для просмотра в любом текстовом редакторе. Преобразование типов выполняется при извлечении данных, благодаря чему слой хранения и логика разбора остаются простыми.
У этого решения есть несколько существенных ограничений. Во-первых, не предусмотрена блокировка файлов, поэтому для безопасной записи каждый файл должен использоваться только одним записывающим экземпляром. Во-вторых, каждая операция записи требует дисковых затрат порядка O(n). Кроме того, если терминал аварийно завершит работу во время сохранения, существует риск неполной записи. В системах, используемых в реальной эксплуатации и предъявляющих строгие требования к надежности, эти ограничения существенны, и их следует учитывать при оценке преимуществ простоты данного подхода.
С учетом этих ограничений хранилище "ключ — значение" на основе плоского файла представляет собой обоснованный, практичный и легко проверяемый механизм сохранения состояния для советников MetaTrader 5.
Представленный в этой статье слой сохранения данных позволяет торговому советнику (EA) возобновлять выполнение с сохранением рабочего контекста, а не начинать работу с нуля после каждого перезапуска терминала. Отделение сохранения состояния от торговой логики с помощью простого типизированного интерфейса позволяет сохранить архитектуру легковесной, прозрачной и легко расширяемой, одновременно повышая устойчивость торговых систем в реальной эксплуатации.
Программы, использованные в статье:
| # | Название | Тип | Описание |
|---|---|---|---|
| 1 | PersistentValue.mqh | Подключаемый файл | Вариантное представление значений со вспомогательными функциями определения типа |
| 2 | PersistentParser.mqh | Подключаемый файл | Токенизация строк, разделение на ключ и значение и проверка корректности |
| 3 | PersistentSerializer.mqh | Подключаемый файл | Сериализация записей кэша в текстовый формат плоского файла |
| 4 | PersistentCache.mqh | Подключаемый файл | Слой кэша в оперативной памяти на основе CHashMap |
| 5 | PersistentStore.mqh | Подключаемый файл | Публичный API хранилища — Get, Set, Delete, Exists, синхронизация с файлом |
| 6 | PersistentStoreDemo.mq5 | Демонстрационный советник | Комплексная демонстрация с моделированием перезапуска и диагностикой |
| 7 | Persistent_Key_Value_Store.zip | Архив в формате ZIP | ZIP-архив, содержащий все приложенные файлы с сохранением их путей относительно корневой папки терминала. |
Перевод с английского произведен MetaQuotes Ltd.
Оригинальная статья: https://www.mql5.com/en/articles/23156
Предупреждение: все права на данные материалы принадлежат MetaQuotes Ltd. Полная или частичная перепечатка запрещена.
Данная статья написана пользователем сайта и отражает его личную точку зрения. Компания MetaQuotes Ltd не несет ответственности за достоверность представленной информации, а также за возможные последствия использования описанных решений, стратегий или рекомендаций.
Особенности написания Пользовательских Индикаторов
Встраивание торговой дисциплины в код (Часть 8): Подтверждение сетапов и контроль разрешения торговли
Интерактивное управление зонами спроса и предложения на MQL5 (Часть III): Анализ зон и отслеживание взаимодействий цены с зоной
- Бесплатные приложения для трейдинга
- 8 000+ сигналов для копирования
- Экономические новости для анализа финансовых рынков
Вы принимаете политику сайта и условия использования