Пользовательские инструменты отладки и профилирования для разработки на MQL5 (Часть I): Расширенное логирование
Вот план:
- Введение
- Создание пользовательского фреймворка для логирования
- Использование фреймворка логирования
- Преимущества пользовательского фреймворка логирования
- Заключение
Введение
Любой, кто потратил время на написание советников, индикаторов или скриптов на MQL5, знаком с этой фрустрацией: живая сделка ведет себя странно, сложная формула выдает неверное число, или ваш советник просто зависает в тот момент, когда рынок накаляется. Обычное быстрое решение — раскидать операторы Print(), запустить тестер стратегий и молиться, чтобы проблема проявилась — перестает работать, как только ваша кодовая база разрастается.
MQL5 создает препятствия для отладки, с которыми обычные языки программирования не сталкиваются. Торговые программы работают в реальном времени (поэтому время имеет значение), оперируют реальными деньгами (поэтому ошибки дороги), и должны оставаться молниеносными даже на волатильных рынках. Встроенные инструменты MetaEditor — отладчик с пошаговым выполнением, Print() и Comment() для базового вывода, а также высокоуровневый профилировщик — полезны, но универсальны. Они просто не созданы для точной диагностики, необходимой вашим торговым алгоритмам.
Именно поэтому создание собственного набора инструментов для отладки и профилирования меняет правила игры. Специализированные утилиты могут обеспечить детальную информацию и индивидуальные рабочие процессы, отсутствующие в стандартном наборе, позволяя вам быстрее находить ошибки, настраивать производительность и обеспечивать качество кода.
Эта серия статей проведет вас через процесс создания именно такого набора. Мы начнем с краеугольного камня — универсального фреймворка логирования, гораздо более мощного, чем беспорядочные вызовы Print(), — а затем добавим продвинутые отладчики, пользовательские профилировщики, среду модульного тестирования и статические анализаторы кода. К концу цикла у вас будет полный пакет, который превратит "тушение пожаров" в проактивный контроль качества.
Каждая часть руководства практична: полные, готовые к использованию примеры на MQL5, подробные объяснения их работы и обоснование каждого проектного решения. Вы получите инструменты, которые можно использовать немедленно, и знания, чтобы адаптировать их под собственные проекты.
На очереди: самая базовая диагностическая потребность из всех — видеть, что именно делает ваша программа, момент за моментом. Давайте создадим этот пользовательский фреймворк логирования.
Создание пользовательского фреймворка для логирования
В этом разделе мы разработаем гибкий и мощный фреймворк для логирования, который выходит далеко за рамки базовой функции Print(), предоставляемой MQL5. Наш пользовательский логгер будет поддерживать множество форматов вывода, уровни серьезности и контекстную информацию, что сделает отладку сложных торговых систем гораздо более эффективной.
Почему обычного Print() недостаточно
Прежде чем мы засучим рукава и приступим к созданию новой системы, полезно понять, почему использование одного только Print() неприемлемо для профессиональных проектов:
- Отсутствие иерархии серьезности – каждое сообщение попадает в одну корзину, поэтому критические оповещения теряются среди рутинных записей.
- Скудный контекст – Print не может сообщить, какая функция вызвала сообщение или каково было состояние приложения в тот момент.
- Однонаправленный вывод – все направляется только на вкладку "Эксперты"; нет встроенного пути для записи в файлы или альтернативные цели.
- Нулевая фильтрация – вы не можете отключить подробные отладочные журналы в рабочей среде, не отключив при этом и важные сообщения об ошибках.
- Неструктурированный текст – свободный формат вывода трудно поддается автоматическому разбору инструментами.
Наш специально разработанный фреймворк логирования решает каждую из этих проблем и закладывает прочную основу для устранения неполадок в сложном торговом коде.
Проектирование архитектуры логгера
Мы построим чистую, модульную, объектно-ориентированную систему, состоящую из трех основных компонентов:- LogLevels (Уровни логирования): перечисление (enum), которое определяет уровни серьезности (DEBUG, INFO, WARN, ERROR, FATAL).
- ILogHandler (Интерфейс обработчика): интерфейс, позволяющий подключать различные "приемники" вывода, такие как FileLogHandler (запись в файл) или ConsoleLogHandler (вывод в консоль/на вкладку "Эксперты").
- CLogger (Логгер): объект-одиночка (синглтон), который управляет обработчиками и предоставляет API для логирования.
Далее мы подробно разберем каждый компонент.
Уровни логирования
Сначала мы определим уровни серьезности в файле LogLevels.mqh:
enum LogLevelenum LogLevel
{
LOG_LEVEL_DEBUG = 0, // Подробная информация для целей отладки.
LOG_LEVEL_INFO = 1, // Общая информация о работе системы.
LOG_LEVEL_WARN = 2, // Предупреждения о потенциальных проблемах, не являющихся критическими.
LOG_LEVEL_ERROR = 3, // Ошибки, которые влияют на части системы, но позволяют продолжить работу.
LOG_LEVEL_FATAL = 4, // Серьезные проблемы, прерывающие выполнение системы.
LOG_LEVEL_OFF = 5 // Отключение логирования.
};
Эти уровни позволяют нам классифицировать сообщения по важности и соответствующим образом фильтровать их. Например, во время разработки вы можете захотеть видеть все сообщения (включая DEBUG), но в рабочей среде вы, возможно, захотите видеть только WARN и выше.
Интерфейс обработчика
Далее мы определим интерфейс для обработчиков журналов в файле ILogHandler.mqh:
#property strict #include "LogLevels.mqh" #include <Arrays/ArrayObj.mqh> // Для управления обработчиками //+------------------------------------------------------------------+ //| Интерфейс: ILogHandler | //| Описание: Определяет контракт для механизмов обработки журналов. | //| Каждый обработчик отвечает за обработку и вывод | //| сообщений журнала определенным способом | //| (например, в консоль, файл, базу данных). | //+------------------------------------------------------------------+ Интерфейс ILogHandler { //--- Метод для настройки обработчика с конкретными параметрами virtual bool Setup(const string settings=""); //--- Метод для обработки и вывода сообщения журнала virtual void Log(const datetime time, const LogLevel level, const string origin, const string message, const long expert_id=0); //--- Метод для выполнения необходимых действий по очистке virtual void Shutdown(); }; //+------------------------------------------------------------------+
Этот заголовочный файл, ILogHandler.mqh, определяет ключевой компонент фреймворка логирования: интерфейс ILogHandler. Интерфейс в MQL5 действует как шаблон или контракт, определяющий набор методов, которые должен предоставить любой класс, реализующий этот интерфейс. Назначение ILogHandler — стандартизировать взаимодействие различных механизмов вывода журналов (таких как запись в консоль или файл) с главным классом логгера.
Сам интерфейс ILogHandler объявляет три виртуальных метода, которые должны быть реализованы в конкретных классах-обработчиках:- virtual bool Setup(const string settings=""): Этот метод предназначен для инициализации и настройки конкретного обработчика журналов. Он принимает необязательный строковый аргумент (settings), который можно использовать для передачи параметров конфигурации (таких как пути к файлам, строки форматирования или минимальные уровни логирования) обработчику на этапе его настройки. Метод возвращает true, если настройка прошла успешно, и false в противном случае, позволяя главному логгеру узнать, готов ли обработчик к использованию.
- virtual void Log(const datetime time, const LogLevel level, const string origin, const string message, const long expert_id=0): Это основной метод, отвечающий за обработку и вывод одного сообщения журнала. Он получает все необходимые детали о событии логирования: временную метку (time), уровень серьезности (level из LogLevels.mqh), источник или происхождение сообщения (origin), непосредственно текст сообщения журнала (message) и опциональный идентификатор советника (expert_id). Каждый реализующий класс будет определять, как форматировать и куда отправлять эту информацию в соответствии со своим конкретным назначением (например, Print в консоль, запись в файл).
- virtual void Shutdown(): Этот метод предназначен для выполнения операций очистки, когда обработчик журналов больше не нужен, обычно во время последовательности завершения работы главного логгера или самого приложения. Реализации могут использовать этот метод для закрытия открытых файловых дескрипторов, освобождения выделенных ресурсов или сброса любых буферизированных данных для гарантии сохранения всех журналов перед завершением программы.
Определяя этот стандартный интерфейс, фреймворк логирования достигает гибкости и расширяемости. Главный класс CLogger может управлять коллекцией различных объектов ILogHandler, отправляя сообщения журнала каждому из них, не вникая в конкретные детали работы каждого обработчика. Новые места назначения вывода могут быть добавлены простым созданием новых классов, реализующих интерфейс ILogHandler.
Обработчик вывода в консоль
Этот заголовочный файл предоставляет класс ConsoleLogHandler, конкретную реализацию интерфейса ILogHandler. Его конкретное назначение — направлять отформатированные сообщения журнала на вкладку "Эксперты" платформы MetaTrader 5, которая служит областью вывода в консоль во время работы советника или скрипта.
#property strict #include "ILogHandler.mqh" #include "LogLevels.mqh" //+------------------------------------------------------------------+ //| Class: ConsoleLogHandler | //| Description: Implements ILogHandler to output log messages to | //| the MetaTrader 5 Experts tab (console). | //+------------------------------------------------------------------+ class ConsoleLogHandler : public ILogHandler { private: LogLevel m_min_level; // Minimum level to log string m_format; // Log message format string //--- Helper to format the log message string FormatMessage(const datetime time, const LogLevel level, const string origin, const string message); //--- Helper to get string representation of LogLevel string LogLevelToString(const LogLevel level); public: ConsoleLogHandler(const LogLevel min_level = LOG_LEVEL_INFO, const string format = "[{time}] {level}: {origin} - {message}"); ~ConsoleLogHandler(); //--- ILogHandler implementation virtual bool Setup(const string settings="") override; virtual void Log(const datetime time, const LogLevel level, const string origin, const string message, const long expert_id=0) override; virtual void Shutdown() override; //--- Setters void SetMinLevel(const LogLevel level) { m_min_level = level; } void SetFormat(const string format) { m_format = format; } };
Класс ConsoleLogHandler наследует публично от ILogHandler, что означает, что он обязуется предоставить реализации методов Setup, Log и Shutdown, определенных в интерфейсе. Он содержит две закрытые переменные-члена: m_min_level типа LogLevel хранит минимальный уровень серьезности, который должно иметь сообщение, чтобы быть записанным этим обработчиком, а m_format типа string содержит шаблон, используемый для форматирования выводимого сообщения. Он также объявляет закрытые вспомогательные методы FormatMessage и LogLevelToString, а также публичные методы для реализации интерфейса и установщики (сеттеры) для своих закрытых членов.
//+------------------------------------------------------------------+ //| Constructor | //+------------------------------------------------------------------+ ConsoleLogHandler::ConsoleLogHandler(const LogLevel min_level = LOG_LEVEL_INFO, const string format = "[{time}] {level}: {origin} - {message}") { m_min_level = min_level; m_format = format; // No specific setup needed for console logging initially } //+------------------------------------------------------------------+ //| Destructor | //+------------------------------------------------------------------+ ConsoleLogHandler::~ConsoleLogHandler() { // No specific cleanup needed }
Конструктор инициализирует новый объект ConsoleLogHandler. Он принимает два необязательных аргумента: min_level (по умолчанию LOG_LEVEL_INFO) и format (по умолчанию стандартный шаблон "[{time}] {level}: {origin} - {message}"). Эти аргументы используются для установки начальных значений переменных-членов m_min_level и m_format соответственно. Это позволяет пользователям настроить уровень фильтрации и внешний вид вывода обработчика при его создании.
Деструктор отвечает за освобождение ресурсов при уничтожении объекта ConsoleLogHandler. В данной конкретной реализации нет динамически выделяемых ресурсов или открытых дескрипторов, которыми этот класс управляет напрямую, поэтому тело деструктора пусто, что указывает на отсутствие необходимости в специальных действиях по очистке для этого обработчика.
//+------------------------------------------------------------------+ //| Настройка | //+------------------------------------------------------------------+ bool ConsoleLogHandler::Setup(const string settings="") { // Настройки можно использовать для разбора формата или min_level, но пока мы используем аргументы конструктора // Пример: разобрать строку настроек при необходимости return true; } //+------------------------------------------------------------------+ //| Log | //+------------------------------------------------------------------+ void ConsoleLogHandler::Log(const datetime time, const LogLevel level, const string origin, const string message, const long expert_id=0) { //Проверяем, соответствует ли уровень сообщения минимальному требованию if(level >= m_min_level && level < LOG_LEVEL_OFF) { //Форматируем и выводим сообщение на вкладку "Эксперты" Print(FormatMessage(time, level, origin, message)); } } //+------------------------------------------------------------------+ //| Завершение работы | //+------------------------------------------------------------------+ void ConsoleLogHandler::Shutdown() { //Для консольного логирования не требуется специальных действий при завершении PrintFormat("%s: ConsoleLogHandler shutdown.", __FUNCTION__); }
- Метод Setup ((ConsoleLogHandler::Setup):
Этот метод реализует функцию Setup, требуемую интерфейсом ILogHandler. Хотя он предназначен для конфигурации, текущая реализация не использует строковый параметр settings, так как основная настройка (минимальный уровень и формат) выполняется через конструктор. Он просто возвращает true, указывая на то, что обработчик всегда считается готовым к использованию после создания. - Метод Log (ConsoleLogHandler::Log):
Это ключевая реализация действия по записи в журнал для консоли. При вызове главным классом CLogger он сначала проверяет, является ли переданный уровень входящего сообщения большим или равным настроенному m_min_level обработчика, а также меньше, чем LOG_LEVEL_OFF. Если сообщение проходит этот фильтр, метод вызывает частную вспомогательную функцию FormatMessage для создания итоговой выходной строки на основе шаблона m_format и предоставленных деталей журнала (time, level, origin, message). Наконец, он использует встроенную функцию MQL5 Print для отображения отформатированной строки на вкладке "Эксперты". - Метод Shutdown (ConsoleLogHandler::Shutdown):
Этот метод реализует функцию Shutdown из интерфейса. Как и в случае с деструктором, для консольного логирования обычно не требуются специфические действия по завершению работы, такие как закрытие файлов. Данная реализация просто выводит сообщение, указывающее на завершение работы консольного обработчика, обеспечивая подтверждение во время последовательности завершения приложения.
//+------------------------------------------------------------------+ //| FormatMessage | //+------------------------------------------------------------------+ string ConsoleLogHandler::FormatMessage(const datetime time, const LogLevel level, const string origin, const string message) { string formatted_message = m_format; // Replace placeholders StringReplace(formatted_message, "{time}", TimeToString(time, TIME_DATE | TIME_SECONDS)); StringReplace(formatted_message, "{level}", LogLevelToString(level)); StringReplace(formatted_message, "{origin}", origin); StringReplace(formatted_message, "{message}", message); return formatted_message; } //+------------------------------------------------------------------+ //| LogLevelToString | //+------------------------------------------------------------------+ string ConsoleLogHandler::LogLevelToString(const LogLevel level) { switch(level) { case LOG_LEVEL_DEBUG: return "DEBUG"; case LOG_LEVEL_INFO: return "INFO"; case LOG_LEVEL_WARN: return "WARN"; case LOG_LEVEL_ERROR: return "ERROR"; case LOG_LEVEL_FATAL: return "FATAL"; default: return "UNKNOWN"; } } //+------------------------------------------------------------------+
- Вспомогательный метод (FormatMessage):
Эта частная вспомогательная функция принимает исходные данные журнала (время, уровень, источник, сообщение) и строку формата обработчика (m_format) в качестве входных данных. Она заменяет заполнители, такие как {time}, {level}, {origin} и {message}, в строке формата на соответствующие фактические значения. Для форматирования временной метки используется TimeToString, и вызывается LogLevelToString для получения строкового представления уровня серьезности. Полностью отформатированная результирующая строка затем возвращается в метод Log для вывода на печать. - Вспомогательный метод ((LogLevelToString):
Эта частная служебная функция преобразует значение перечисления LogLevel в соответствующее строковое представление (например, LOG_LEVEL_INFO становится "INFO"). Она использует оператор switch для обработки определенных уровней журнала и возвращает "UNKNOWN" для любых неожиданных значений. Это обеспечивает удобочитаемые индикаторы уровня в отформатированном выводе журнала. - Методы установки (SetMinLevel, SetFormat): Эти публичные методы позволяют пользователю изменять конфигурацию обработчика после его создания. SetMinLevel обновляет переменную-член m_min_level, изменяя порог фильтрации для последующих сообщений журнала. SetFormat обновляет переменную-член m_format, изменяя шаблон, используемый для форматирования будущих сообщений журнала.
Обработчик файлов журнала
Этот заголовочный файл содержит класс FileLogHandler — еще одну конкретную реализацию интерфейса ILogHandler. Данный обработчик предназначен для постоянного ведения журнала и записывает отформатированные сообщения в файлы. По сравнению с консольным обработчиком, он включает более продвинутые возможности, такие как автоматическая ротация файлов журнала на основе даты и размера, а также управление количеством сохраняемых файлов журнала.
#property strict #include "ILogHandler.mqh" #include "LogLevels.mqh" //+------------------------------------------------------------------+ //| Класс: FileLogHandler | //| Описание: Реализует ILogHandler для вывода сообщений журнала | //| в файлы с возможностью ротации. | //+------------------------------------------------------------------+ class FileLogHandler : public ILogHandler { private: LogLevel m_min_level; // Минимальный уровень для записи string m_format; // Строка формата сообщения журнала string m_file_path; // Базовый путь для файлов журнала string m_file_prefix; // Префикс для названий файлов журнала int m_file_handle; // Текущий дескриптор файла datetime m_current_day; // Текущий день для ротации int m_max_size_kb; //Максимальный размер файла в КБ перед ротацией int m_max_files; //Максимальное количество сохраняемых файлов журнала //--- Вспомогательный метод для форматирования сообщения журнала string FormatMessage(const datetime time, const LogLevel level, const string origin, const string message); //---Вспомогательный метод для получения строкового представления LogLevel string LogLevelToString(const LogLevel level); //---Вспомогательный метод для создания или ротации файла журнала bool EnsureFileOpen(); //---Вспомогательный метод для генерации названия файла на основе даты string GenerateFileName(const datetime time); //---Вспомогательный метод для выполнения ротации файлов журнала void RotateLogFiles(); //---Вспомогательный метод для проверки превышения размера файла bool IsFileSizeExceeded(); //Добавление пользовательской вспомогательной функции для сортировки строковых массивов void SortStringArray(string &arr[]); //---Новый вспомогательный метод для очистки путей к файлам string CleanPath(const string path); public: FileLogHandler(const string file_path="MQL5\\Logs", const string file_prefix="EA_Log", const LogLevel min_level=LOG_LEVEL_INFO, const string format="[{time}] {level}: {origin} - {message}", const int max_size_kb=1024, const int max_files=5); virtual ~FileLogHandler(); //---Реализация ILogHandler virtual bool Setup(const string settings="") override; virtual void Log(const datetime time, const LogLevel level, const string origin, const string message, const long expert_id=0) override; virtual void Shutdown() override; //---Методы установки void SetFilePath(const string path) { m_file_path = CleanPath(path); } void SetMinLevel(const LogLevel level) { m_min_level = level; } void SetFormat(const string format) { m_format = format; } void SetFilePrefix(const string prefix){ m_file_prefix = prefix; } void SetMaxSizeKB(const int size) { m_max_size_kb = size; } void SetMaxFiles(const int count) { m_max_files = count; } };
Класс FileLogHandler наследуется от ILogHandler. Он содержит несколько закрытых переменных-членов для управления своим состоянием и конфигурацией: m_min_level и m_format (аналогично консольному обработчику), m_file_path (каталог для хранения логов), m_file_prefix (базовое имя для файлов логов), m_file_handle (дескриптор текущего открытого файла лога), m_current_day (используется для логики ежедневной ротации), m_max_size_kb (ограничение на размер одного файла лога в килобайтах) и m_max_files (максимальное количество сохраняемых файлов логов).
Кроме того, в классе объявлены несколько закрытых вспомогательных методов для форматирования, управления файлами и ротации (FormatMessage, LogLevelToString, EnsureFileOpen, GenerateFileName, RotateLogFiles, IsFileSizeExceeded, SortStringArray, CleanPath). Публичные методы включают конструктор, деструктор, реализации методов интерфейса и методы setter для настройки конфигурации.
//+------------------------------------------------------------------+ //| Constructor | //+------------------------------------------------------------------+ FileLogHandler::FileLogHandler(const string file_path, const string file_prefix, const LogLevel min_level, const string format, const int max_size_kb, const int max_files) { m_min_level = min_level; m_format = format; m_file_path = CleanPath(file_path); m_file_prefix = file_prefix; m_file_handle = INVALID_HANDLE; m_current_day = 0; m_max_size_kb = max_size_kb; m_max_files = max_files; // Create directory if it doesn't exist if(!FolderCreate(m_file_path)) { if(GetLastError() != 0) Print("FileLogHandler: Failed to create directory: ", m_file_path, ", error: ", GetLastError()); } } //+------------------------------------------------------------------+ //| Destructor | //+------------------------------------------------------------------+ FileLogHandler::~FileLogHandler() { Shutdown(); }
- Конструктор (FileLogHandler::FileLogHandler):
Конструктор инициализирует FileLogHandler. Он принимает аргументы для пути к файлу, префикса, минимального уровня логирования, строки формата, максимального размера файла и максимального количества файлов, устанавливая соответствующие переменные-члены. Он использует вспомогательный метод CleanPath для обеспечения использования правильных разделителей каталогов в пути к файлу. Важно отметить, что он также пытается создать указанный каталог для логов (m_file_path относительно пути к данным терминала) с помощью FolderCreate, если он еще не существует, гарантируя, что у обработчика есть место для записи файлов. - Деструктор (FileLogHandler::~FileLogHandler):
Деструктор обеспечивает надлежащую очистку путем вызова метода Shutdown. Это гарантирует, что дескриптор текущего открытого файла лога будет закрыт при уничтожении объекта FileLogHandler, предотвращая утечку ресурсов.
//+------------------------------------------------------------------+ //| Setup | //+------------------------------------------------------------------+ bool FileLogHandler::Setup(const string settings) { // Parse settings if provided // Format could be: "path=MQL5/Logs;prefix=MyEA;min_level=INFO;max_size=2048;max_files=10" if(settings != "") { string parts[]; int count = StringSplit(settings, ';', parts); for(int i = 0; i < count; i++) { string key_value[]; if(StringSplit(parts[i], '=', key_value) == 2) { string key = key_value[0]; StringTrimLeft(key); StringTrimRight(key); string value = key_value[1]; StringTrimLeft(value); StringTrimRight(value); if(key == "path") m_file_path = CleanPath(value); else if(key == "prefix") m_file_prefix = value; else if(key == "min_level") { if(value == "DEBUG") m_min_level = LOG_LEVEL_DEBUG; else if(value == "INFO") m_min_level = LOG_LEVEL_INFO; else if(value == "WARN") m_min_level = LOG_LEVEL_WARN; else if(value == "ERROR") m_min_level = LOG_LEVEL_ERROR; else if(value == "FATAL") m_min_level = LOG_LEVEL_FATAL; } else if(key == "max_size") m_max_size_kb = (int)StringToInteger(value); else if(key == "max_files") m_max_files = (int)StringToInteger(value); } } } return true; } //+------------------------------------------------------------------+ //| Log | //+------------------------------------------------------------------+ void FileLogHandler::Log(const datetime time, const LogLevel level, const string origin, const string message, const long expert_id=0) { // Check if the message level meets the minimum requirement if(level >= m_min_level && level < LOG_LEVEL_OFF) { // Ensure file is open and ready for writing if(EnsureFileOpen()) { // Format the message string formatted_message = FormatMessage(time, level, origin, message); // Write to file FileWriteString(m_file_handle, formatted_message + "\r\n"); // Flush to ensure data is written immediately FileFlush(m_file_handle); // Check if rotation is needed if(IsFileSizeExceeded()) { FileClose(m_file_handle); m_file_handle = INVALID_HANDLE; RotateLogFiles(); EnsureFileOpen(); } } } } //+------------------------------------------------------------------+ //| Shutdown | //+------------------------------------------------------------------+ void FileLogHandler::Shutdown() { if(m_file_handle != INVALID_HANDLE) { FileClose(m_file_handle); m_file_handle = INVALID_HANDLE; } }
- Метод Setup (FileLogHandler::Setup):
Этот метод реализует функцию Setup из интерфейса. Он предоставляет альтернативный способ настройки обработчика после его создания с помощью единой строки параметров (например, "path=MQL5/Logs;prefix=MyEA;max_size=2048"). Метод анализирует эту строку, разделяя ее на пары "ключ-значение", и обновляет соответствующие переменные-члены, такие как m_file_path, m_file_prefix, m_min_level, m_max_size_kb и m_max_files. Это позволяет загружать конфигурацию из внешних источников при необходимости. После анализа метод возвращает true. - Метод Log (FileLogHandler::Log):
Этот метод реализует основную логику записи в файл журнала. Сначала он проверяет, соответствует ли уровень сообщения требованию m_min_level. Если соответствует, вызывается метод EnsureFileOpen, чтобы убедиться, что открыт корректный файл журнала (при необходимости выполняется ежедневная ротация). Если файл успешно открыт, сообщение форматируется с помощью FormatMessage, и отформатированная строка вместе с символом новой строки (\r\n) записывается в файл через FileWriteString. Затем вызывается FileFlush для обеспечения немедленной записи данных на диск, что важно для сохранения логов даже в случае сбоя приложения. Наконец, с помощью IsFileSizeExceeded проверяется, не превышает ли текущий размер файла ограничение m_max_size_kb. Если лимит превышен, текущий файл закрывается, запускается RotateLogFiles для управления старыми файлами и с помощью EnsureFileOpen открывается новый файл. - Метод Shutdown (FileLogHandler::Shutdown):
Этот метод реализует требование Shutdown из интерфейса. Его основная задача — закрыть текущий дескриптор открытого файла журнала (m_file_handle) с помощью FileClose, если он действителен (!= INVALID_HANDLE). Это гарантирует, что файл будет правильно закрыт, а все буферизированные данные будут записаны при завершении работы регистратора.
//+------------------------------------------------------------------+ //| FormatMessage | //+------------------------------------------------------------------+ string FileLogHandler::FormatMessage(const datetime time, const LogLevel level, const string origin, const string message) { string formatted_message = m_format; // Replace placeholders StringReplace(formatted_message, "{time}", TimeToString(time, TIME_DATE | TIME_SECONDS)); StringReplace(formatted_message, "{level}", LogLevelToString(level)); StringReplace(formatted_message, "{origin}", origin); StringReplace(formatted_message, "{message}", message); return formatted_message; } //+------------------------------------------------------------------+ //| LogLevelToString | //+------------------------------------------------------------------+ string FileLogHandler::LogLevelToString(const LogLevel level) { switch(level) { case LOG_LEVEL_DEBUG: return "DEBUG"; case LOG_LEVEL_INFO: return "INFO"; case LOG_LEVEL_WARN: return "WARN"; case LOG_LEVEL_ERROR: return "ERROR"; case LOG_LEVEL_FATAL: return "FATAL"; default: return "UNKNOWN"; } }
Вспомогательные методы (FormatMessage, LogLevelToString): Эти частные вспомогательные методы функционируют идентично своим аналогам из ConsoleLogHandler, обеспечивая форматирование сообщений на основе строки m_format и преобразование перечислений LogLevel в читаемые строки.
//+------------------------------------------------------------------+ //| EnsureFileOpen | //+------------------------------------------------------------------+ bool FileLogHandler::EnsureFileOpen() { datetime current_time = TimeCurrent(); MqlDateTime time_struct; TimeToStruct(current_time, time_struct); //Создаем datetime, представляющий только текущий день (время установлено на 00:00:00) MqlDateTime day_struct; day_struct.year = time_struct.year; day_struct.mon = time_struct.mon; day_struct.day = time_struct.day; day_struct.hour = 0; day_struct.min = 0; day_struct.sec = 0; datetime current_day = StructToTime(day_struct); //Проверяем, нужно ли открыть новый файл (либо первый запуск, либо новый день) if(m_file_handle == INVALID_HANDLE || m_current_day != current_day) { //Закрываем существующий файл, если он открыт if(m_file_handle != INVALID_HANDLE) { FileClose(m_file_handle); m_file_handle = INVALID_HANDLE; } //Обновляем текущий день m_current_day = current_day; //Генерируем новое название файла string file_name = GenerateFileName(current_time); //Открываем файл для записи (добавление, если существует) m_file_handle = FileOpen(file_name, FILE_WRITE | FILE_READ | FILE_TXT); if(m_file_handle == INVALID_HANDLE) { Print("FileLogHandler: Failed to open log file: ", file_name, ", error: ", GetLastError()); return false; } //Перемещаемся в конец файла для добавления FileSeek(m_file_handle, 0, SEEK_END); } return true; } //+------------------------------------------------------------------+ //| GenerateFileName | //+------------------------------------------------------------------+ string FileLogHandler::GenerateFileName(const datetime time) { MqlDateTime time_struct; TimeToStruct(time, time_struct); string date_str = StringFormat("%04d%02d%02d", time_struct.year, time_struct.mon, time_struct.day); return m_file_path + "\\" + m_file_prefix + "_" + date_str + ".log"; } //+------------------------------------------------------------------+ //| IsFileSizeExceeded | //+------------------------------------------------------------------+ bool FileLogHandler::IsFileSizeExceeded() { if(m_file_handle != INVALID_HANDLE) { //Получаем текущую позицию (размер файла) ulong size = FileSize(m_file_handle); //Проверяем, превышает ли размер лимит (преобразуем КБ в байты) return (size > (ulong)m_max_size_kb * 1024); } return false; }
- Вспомогательный метод ((EnsureFileOpen):
Этот ключевой вспомогательный метод управляет открытием файлов журнала и их ежедневной ротацией. Он сравнивает текущую дату (полученную из TimeCurrent()) с сохраненной m_current_day. Если дескриптор файла недействителен или день изменился, метод закрывает существующий дескриптор (если есть), обновляет m_current_day, генерирует новое имя файла с помощью GenerateFileName (которое включает дату) и открывает этот новый файл в режиме записи/чтения (FILE_WRITE | FILE_READ | FILE_TXT). Используется FileSeek для перемещения в конец файла, обеспечивая добавление новых записей. Метод возвращает true, если файл успешно открыт или уже открыт, и false в случае неудачи. - Вспомогательный метод (GenerateFileName):
Эта утилита генерирует полный путь для файла журнала на основе текущего времени. Она форматирует часть даты из времени в строку формата ГГГГММДД и объединяет ее с настроенными m_file_path, m_file_prefix и расширением .log. - Вспомогательный метод (IsFileSizeExceeded):
Эта функция проверяет, превысил ли размер текущего открытого файла журнала (m_file_handle) установленный лимит m_max_size_kb. Она получает размер файла с помощью FileSize и сравнивает его с лимитом (преобразованным в байты). Функция возвращает true, если размер превышен, и false в противном случае.
//+------------------------------------------------------------------+ //| RotateLogFiles | //+------------------------------------------------------------------+ void FileLogHandler::RotateLogFiles() { // Получаем список файлов журнала string terminal_path = TerminalInfoString(TERMINAL_DATA_PATH); string full_path = terminal_path + "\\" + m_file_path; string file_pattern = m_file_prefix + "_*.log"; string files[]; int file_count = 0; long search_handle = FileFindFirst(full_path + "\\" + file_pattern, files[file_count]); if(search_handle != INVALID_HANDLE) { file_count++; // Находим все соответствующие файлы while(FileFindNext(search_handle, files[file_count])) { file_count++; ArrayResize(files, file_count + 1); } // Закрываем дескриптор поиска FileFindClose(search_handle); } // Изменяем размер массива до фактического количества найденных файлов перед сортировкой ArrayResize(files, file_count); // Сортируем строковый массив с помощью пользовательской сортировки SortStringArray(files); // Удаляем самые старые файлы, если их слишком много int files_to_delete = file_count - m_max_files + 1; // +1 для нового файла, который мы создадим if(files_to_delete > 0) { for(int i = 0; i < files_to_delete; i++) { if(!FileDelete(m_file_path + "\\" + files[i])) Print("FileLogHandler: Не удалось удалить старый файл журнала: ", files[i], ", ошибка: ", GetLastError()); } } } //+------------------------------------------------------------------+ //| SortStringArray | //+------------------------------------------------------------------+ void FileLogHandler::SortStringArray(string &arr[]) { int n = ArraySize(arr); for(int i = 0; i < n - 1; i++) { for(int j = i + 1; j < n; j++) { if(arr[i] > arr[j]) { string temp = arr[i]; arr[i] = arr[j]; arr[j] = temp; } } } } //+------------------------------------------------------------------+ //| Новая реализация: CleanPath | //+------------------------------------------------------------------+ string FileLogHandler::CleanPath(const string path) { string result = path; // Заменяем все "/" на "\\" StringReplace(result, "/", "\\"); return result; } //+------------------------------------------------------------------+
- Вспомогательный метод (RotateLogFiles):
Этот метод реализует политику хранения файлов журнала. Он находит все файлы в каталоге журналов, соответствующие шаблону (m_file_prefix_*.log), используя FileFindFirst и FileFindNext. Имена файлов сохраняются в строковом массиве, сортируются по алфавиту (что обычно соответствует хронологическому порядку благодаря формату даты в имени файла) с помощью вспомогательного метода SortStringArray. Затем вычисляется, сколько файлов превышает лимит m_max_files, и удаляются самые старые из них (те, которые находятся в начале отсортированного списка) с помощью FileDelete. - Вспомогательный метод (SortStringArray):
Это простая реализация пузырьковой сортировки, предназначенная специально для сортировки массива имен файлов журнала, полученного в RotateLogFiles. Она используется, поскольку в стандартной библиотеке MQL5 отсутствует встроенная функция сортировки для строковых массивов. - Вспомогательный метод (CleanPath)
:Эта утилита обеспечивает использование в путях к каталогам обратной косой черты (), ожидаемой файловыми функциями MQL5, заменяя любые найденные во входной строке пути прямые косые черты (/). - Методы установки (SetFilePath, SetMinLevel и т.д.):
Эти публичные методы позволяют изменять параметры конфигурации обработчика (путь, префикс, уровень, формат, ограничения размера) после его первоначального создания, обеспечивая гибкость.
CLogger
Этот заголовочный файл определяет класс CLogger, который выступает в роли центрального оркестратора для всего механизма протоколирования. Он реализован с использованием паттерна проектирования Singleton, что гарантирует существование только одного экземпляра регистратора во всем приложении. Этот экземпляр управляет всеми зарегистрированными обработчиками журналов и обеспечивает основной интерфейс для кода пользователя\ для отправки сообщений журнала.#property strict #include "LogLevels.mqh" #include "ILogHandler.mqh" //+------------------------------------------------------------------+ //| Class: CLogger | //| Description: Singleton class for managing and dispatching log | //| messages to registered handlers. | //+------------------------------------------------------------------+ class CLogger { private: static CLogger *s_instance; ILogHandler* m_handlers[]; LogLevel m_global_min_level; long m_expert_magic; string m_expert_name; //--- Private constructor for Singleton CLogger(); ~CLogger(); public: //--- Get the singleton instance static CLogger* Instance(); //--- Cleanup the singleton instance static void Release(); //--- Handler management bool AddHandler(ILogHandler *handler); void ClearHandlers(); //--- Configuration void SetGlobalMinLevel(const LogLevel level); void SetExpertInfo(const long magic, const string name); //--- Logging methods void Log(const LogLevel level, const string origin, const string message); void Debug(const string origin, const string message); void Info(const string origin, const string message); void Warn(const string origin, const string message); void Error(const string origin, const string message); void Fatal(const string origin, const string message); //--- Formatted logging methods void LogFormat(const LogLevel level, const string origin, const string formatted_message); void DebugFormat(const string origin, const string formatted_message); void InfoFormat(const string origin, const string formatted_message); void WarnFormat(const string origin, const string formatted_message); void ErrorFormat(const string origin, const string formatted_message); void FatalFormat(const string origin, const string formatted_message); };
Класс CLogger содержит несколько закрытых членов. s_instance - это статический указатель на единственный экземпляр самого класса. m_handlers - это динамический массив указателей ILogHandler, хранящий ссылки на все активные обработчики логов (например, консольные или файловые). m_global_min_level устанавливает глобальный порог фильтрации; сообщения ниже этого уровня игнорируются еще до отправки отдельным обработчикам. m_expert_magic и m_expert_name хранят необязательную информацию об эксперте, использующем логгер, которая может быть включена в сообщения лога для лучшего контекста.
Конструктор и деструктор являются частными, чтобы обеспечить соблюдение шаблона Singleton. Публичные методы обеспечивают доступ к экземпляру, управление обработчиками, конфигурацию и различные функции протоколирования.
//+------------------------------------------------------------------+ //| Static instance initialization | //+------------------------------------------------------------------+ CLogger *CLogger::s_instance = NULL; //+------------------------------------------------------------------+ //| Get Singleton Instance | //+------------------------------------------------------------------+ CLogger* CLogger::Instance() { if(s_instance == NULL) { s_instance = new CLogger(); } return s_instance; } //+------------------------------------------------------------------+ //| Release Singleton Instance | //+------------------------------------------------------------------+ void CLogger::Release() { if(s_instance != NULL) { delete s_instance; s_instance = NULL; } } //+------------------------------------------------------------------+ //| Constructor (Private) | //+------------------------------------------------------------------+ CLogger::CLogger() { m_global_min_level = LOG_LEVEL_DEBUG; m_expert_magic = 0; m_expert_name = ""; } //+------------------------------------------------------------------+ //| Destructor | //+------------------------------------------------------------------+ CLogger::~CLogger() { ClearHandlers(); }
- Реализация Singleton (Instance, Release, приватный конструктор):
Шаблон Singleton реализуется через статический метод Instance(), который создает объект CLogger при первом вызове и возвращает тот же экземпляр при последующих вызовах. Конструктор (CLogger::CLogger) является приватным, что предотвращает прямое создание экземпляров извне класса; он инициализирует значения по умолчанию для глобального минимального уровня и информации об эксперте. Статический метод Release() предоставляется для явного удаления экземпляра singleton и освобождения ресурсов, обычно вызывается при завершении работы приложения. - Деструктор (CLogger::~CLogger):
Деструктор вызывается при удалении экземпляра singleton через метод Release(). Его основная обязанность — очистить управляемые обработчики путем вызова метода ClearHandlers, что гарантирует вызов метода Shutdown для каждого обработчика и удаление самих объектов обработчиков.
//+------------------------------------------------------------------+ //| AddHandler | //+------------------------------------------------------------------+ bool CLogger::AddHandler(ILogHandler *handler) { if(CheckPointer(handler) == POINTER_INVALID) { Print("CLogger::AddHandler - Error: Invalid handler pointer."); return false; } int size = ArraySize(m_handlers); ArrayResize(m_handlers, size + 1); m_handlers[size] = handler; return true; } //+------------------------------------------------------------------+ //| ClearHandlers | //+------------------------------------------------------------------+ void CLogger::ClearHandlers() { for(int i = 0; i < ArraySize(m_handlers); i++) { ILogHandler *handler = m_handlers[i]; if(CheckPointer(handler) != POINTER_INVALID) { handler.Shutdown(); delete handler; } } ArrayResize(m_handlers, 0); } //+------------------------------------------------------------------+ //| SetGlobalMinLevel | //+------------------------------------------------------------------+ void CLogger::SetGlobalMinLevel(const LogLevel level) { m_global_min_level = level; } //+------------------------------------------------------------------+ //| SetExpertInfo | //+------------------------------------------------------------------+ void CLogger::SetExpertInfo(const long magic, const string name) { m_expert_magic = magic; m_expert_name = name; }
- Управление обработчиками (AddHandler, ClearHandlers):
Метод AddHandler позволяет добавить новый обработчик журнала (любой объект, реализующий интерфейс ILogHandler) во внутренний список регистратора (m_handlers). Он проверяет корректность указателя, изменяет размер динамического массива и добавляет обработчик. Метод ClearHandlers выполняет итерацию по массиву m_handlers, вызывает метод Shutdown для каждого корректного обработчика, удаляет сам объект обработчика (предполагая, что регистратор берет на себя владение) и, наконец, очищает массив. Это критически важно для правильного освобождения ресурсов. - Конфигурация (SetGlobalMinLevel, SetExpertInfo):
Эти методы позволяют настраивать поведение регистратора. SetGlobalMinLevel изменяет глобальный порог фильтрации (m_global_min_level), влияя на все сообщения до их передачи обработчикам. SetExpertInfo позволяет установить магический номер и имя советника, которые затем могут автоматически включаться в сообщения журнала обработчиками для лучшей идентификации, особенно когда несколько советников могут вести запись одновременно.
//+------------------------------------------------------------------+ //| Log | //+------------------------------------------------------------------+ void CLogger::Log(const LogLevel level, const string origin, const string message) { // Check global level first if(level < m_global_min_level || level >= LOG_LEVEL_OFF) return; datetime current_time = TimeCurrent(); string effective_origin = origin; if(m_expert_name != "") effective_origin = m_expert_name + "::" + origin; // Dispatch to all registered handlers for(int i = 0; i < ArraySize(m_handlers); i++) { ILogHandler *handler = m_handlers[i]; if(CheckPointer(handler) != POINTER_INVALID) { handler.Log(current_time, level, effective_origin, message, m_expert_magic); } } } //+------------------------------------------------------------------+ //| Convenience Logging Methods | //+------------------------------------------------------------------+ void CLogger::Debug(const string origin, const string message) { Log(LOG_LEVEL_DEBUG, origin, message); } void CLogger::Info(const string origin, const string message) { Log(LOG_LEVEL_INFO, origin, message); } void CLogger::Warn(const string origin, const string message) { Log(LOG_LEVEL_WARN, origin, message); } void CLogger::Error(const string origin, const string message) { Log(LOG_LEVEL_ERROR, origin, message); } void CLogger::Fatal(const string origin, const string message) { Log(LOG_LEVEL_FATAL, origin, message); } //+------------------------------------------------------------------+ //| LogFormat | //+------------------------------------------------------------------+ void CLogger::LogFormat(const LogLevel level, const string origin, const string formatted_message) { // Check global level first if(level < m_global_min_level || level >= LOG_LEVEL_OFF) return; Log(level, origin, formatted_message); } //+------------------------------------------------------------------+ //| Convenience Formatted Logging Methods | //+------------------------------------------------------------------+ void CLogger::DebugFormat(const string origin, const string formatted_message) { LogFormat(LOG_LEVEL_DEBUG, origin, formatted_message); } void CLogger::InfoFormat(const string origin, const string formatted_message) { LogFormat(LOG_LEVEL_INFO, origin, formatted_message); } void CLogger::WarnFormat(const string origin, const string formatted_message) { LogFormat(LOG_LEVEL_WARN, origin, formatted_message); } void CLogger::ErrorFormat(const string origin, const string formatted_message) { LogFormat(LOG_LEVEL_ERROR, origin, formatted_message); } void CLogger::FatalFormat(const string origin, const string formatted_message) { LogFormat(LOG_LEVEL_FATAL, origin, formatted_message); } //+------------------------------------------------------------------+
- Основной метод логирования (Log):
Это центральный метод, который принимает запросы на запись в журнал. Сначала он проверяет, соответствует ли уровень сообщения глобальному минимальному уровню m_global_min_level. Если проверка пройдена, метод получает текущее время и формирует строку effective_origin, потенциально добавляя в начало настроенное имя советника m_expert_name. Затем он выполняет итерацию по массиву обработчиков m_handlers и вызывает метод Log каждого корректного обработчика, передавая ему временную метку, уровень, источник, сообщение и магический номер эксперта. Это эффективно распределяет сообщение журнала по всем активным местам вывода. - Удобные методы логирования (Debug, Info, Warn, Error, Fatal):
Эти публичные методы предоставляют более простой интерфейс для записи сообщений с определенными уровнями серьезности. Каждый метод (например, Debug, Info) просто вызывает основной метод Log с соответствующим значением перечисления LogLevel (LOG_LEVEL_DEBUG, LOG_LEVEL_INFO и т.д.), сокращая объем кода, необходимого в приложении пользователя для записи сообщения. - Методы логирования с форматированием (LogFormat, DebugFormat и т.д. ):
Эти методы предлагают альтернативный способ записи уже отформатированных сообщений. LogFormat принимает предварительно отформатированную строку сообщения и вызывает основной метод Log. Удобные методы, такие как DebugFormat, InfoFormat и т.д., просто вызывают LogFormat с соответствующим уровнем серьезности. Это полезно, если логика форматирования сообщения сложна и обрабатывается в другом месте перед вызовом регистратора.
С завершением реализации CLogger пришло время увидеть его в действии.
Использование фреймворка логирования
Этот советник служит практической демонстрацией того, как интегрировать и использовать пользовательский фреймворк логирования для MQL5 (состоящий из CLogger, ILogHandler, ConsoleLogHandler и FileLogHandler). Он показывает настройку, конфигурацию, использование во время работы и очистку компонентов логирования в рамках стандартной структуры советника.
Начальный раздел LoggingExampleEA.mq5 устанавливает стандартные свойства советника и включает необходимые компоненты из пользовательского фреймворка логирования.
После свойств, директивы #include имеют решающее значение для интеграции функциональности логирования. CLogger.mqh подключает определение основного класса регистратора. ConsoleLogHandler.mqh включает класс для логирования в консоль MetaTrader (вкладка "Эксперты"). FileLogHandler.mqh подключает класс, отвечающий за запись в файлы. Эти включения делают классы и функции, определенные в данных заголовочных файлах, доступными для использования в этом советнике.
Входные параметры (input):
// Input parameters input int MagicNumber = 654321; // EA Magic Number input double LotSize = 0.01; // Fixed lot size input int StopLossPips = 50; // Stop Loss in pips input int TakeProfitPips = 100; // Take Profit in pips input LogLevel ConsoleLogLevel = LOG_LEVEL_INFO; // Minimum level for console output input LogLevel FileLogLevel = LOG_LEVEL_DEBUG; // Minimum level for file output
В этом разделе определяются внешние параметры, которые пользователи могут настраивать при присоединении советника к графику. Эти входные параметры позволяют настраивать торговое поведение советника и, что важно, его настройки логирования.
- input int MagicNumber = 654321; : Это стандартный параметр советника, используемый для идентификации ордеров, размещенных данным конкретным экземпляром советника. Он помогает отличать его сделки от сделок других советников или ручных сделок.
- input double LotSize = 0.01; : Определяет фиксированный торговый объем (размер лота) для ордеров, размещаемых советником.
- input int StopLossPips = 50; : Устанавливает расстояние стоп-лосса в пипсах для ордеров.
- input int TakeProfitPips = 100; : Устанавливает расстояние тейк-профита в пипсах для ордеров.
- input LogLevel ConsoleLogLevel = LOG_LEVEL_INFO; : Этот параметр позволяет пользователю выбирать минимальный уровень серьезности для сообщений, которые должны отображаться на вкладке "Эксперты" MetaTrader (консоль). Он использует тип перечисления LogLevel, определенный в LogLevels.mqh. По умолчанию установлен LOG_LEVEL_INFO, что означает, что сообщения уровней INFO, WARN, ERROR и FATAL будут показываться в консоли, а сообщения DEBUG будут подавлены.
- input LogLevel FileLogLevel = LOG_LEVEL_DEBUG; : Аналогично, этот параметр устанавливает минимальный уровень серьезности для сообщений, записываемых в файл журнала. Он также использует перечисление LogLevel. Значение по умолчанию — LOG_LEVEL_DEBUG, что означает, что все сообщения, включая подробную отладочную информацию, будут сохраняться в файл журнала. Это позволяет сделать вывод в консоль менее подробным при нормальной работе, сохраняя при этом детальные журналы для последующего анализа или устранения неполадок.
Эти параметры, специфичные для логирования, демонстрируют, как фреймворк может быть легко настроен извне, позволяя пользователям регулировать детализацию логирования без изменения кода советника.
// Глобальный указатель на регистратор (опционально, можно использовать CLogger::Instance() напрямую) CLogger *g_logger = NULL;
Советник объявляет одну глобальную переменную: CLogger *g_logger = NULL; : Эта строка объявляет указатель с именем g_logger, который может указывать на объект класса CLogger. Он инициализируется значением NULL, то есть изначально не указывает на какой-либо действительный объект. Этот глобальный указатель предназначен для хранения единственного экземпляра CLogger, полученного через шаблон singleton (CLogger::Instance()).
Хотя можно использовать статический метод CLogger::Instance() напрямую везде, где требуется логирование, хранение экземпляра в этой глобальной переменной после его получения в OnInit() предоставляет удобный способ доступа к объекту регистратора из различных функций (OnTick, OnDeinit, OnChartEvent) без повторных вызовов CLogger::Instance(). Он действует как кэшированный указатель на регистратор-одиночку.
OnInit():
//+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit() { //--- Get the logger instance g_logger = CLogger::Instance(); if(CheckPointer(g_logger) == POINTER_INVALID) { Print("Critical Error: Failed to get Logger instance!"); return(INIT_FAILED); } //--- Set EA information for context in logs g_logger.SetExpertInfo(MagicNumber, MQL5InfoString(MQL5_PROGRAM_NAME)); //--- Configure Handlers --- // 1. Console Handler ConsoleLogHandler *console_handler = new ConsoleLogHandler(ConsoleLogLevel); if(CheckPointer(console_handler) != POINTER_INVALID) { // Optionally customize format // console_handler.SetFormat("[{level}] {message}"); if(!g_logger.AddHandler(console_handler)) { Print("Warning: Failed to add ConsoleLogHandler."); delete console_handler; // Clean up if not added } } else { Print("Warning: Failed to create ConsoleLogHandler."); } // 2. File Handler string log_prefix = MQL5InfoString(MQL5_PROGRAM_NAME) + "_" + IntegerToString(MagicNumber); FileLogHandler *file_handler = new FileLogHandler("MQL5/Logs/EA_Logs", // Directory relative to MQL5/Files log_prefix, // File name prefix FileLogLevel, // Minimum level to log to file "[{time}] {level} ({origin}): {message}", // Format 2048, // Max file size in KB (e.g., 2MB) 10); // Max number of log files to keep if(CheckPointer(file_handler) != POINTER_INVALID) { if(!g_logger.AddHandler(file_handler)) { Print("Warning: Failed to add FileLogHandler."); delete file_handler; // Clean up if not added } } else { Print("Warning: Failed to create FileLogHandler."); } //--- Log initialization message g_logger.Info(__FUNCTION__, "Expert Advisor initialized successfully."); g_logger.Debug(__FUNCTION__, StringFormat("Settings: Lots=%.2f, SL=%d, TP=%d, ConsoleLevel=%s, FileLevel=%s", LotSize, StopLossPips, TakeProfitPips, EnumToString(ConsoleLogLevel), EnumToString(FileLogLevel))); //--- succeed return(INIT_SUCCEEDED); }
В этом примере функция OnInit() имеет решающее значение для настройки и конфигурирования пользовательского фреймворка логирования.
g_logger = CLogger::Instance();
Этот статический метод гарантирует, что существует только один объект CLogger. Возвращаемый указатель сохраняется в глобальной переменной g_logger для более удобного доступа в дальнейшем. Затем выполняется базовая проверка ошибок с помощью CheckPointer, чтобы убедиться, что экземпляр был успешно получен; если нет, в стандартный журнал выводится критическая ошибка, и инициализация завершается неудачей (INIT_FAILED).
g_logger.SetExpertInfo(MagicNumber, MQL5InfoString(MQL5_PROGRAM_NAME));
Эта строка настраивает регистратор с контекстной информацией о использующем его советнике. Она передает MagicNumber (из входных параметров) и имя советника (полученное с помощью MQL5InfoString(MQL5_PROGRAM_NAME)). Эта информация может автоматически включаться в сообщения журнала обработчиками (в зависимости от их строки формата), что упрощает идентификацию журналов от конкретных советников, особенно если запущено несколько советников.
Динамически создается ConsoleLogHandler с помощью new:
ConsoleLogHandler *console_handler = new ConsoleLogHandler(ConsoleLogLevel);
Он настраивается непосредственно в конструкторе с минимальным уровнем логирования, указанным входным параметром ConsoleLogLevel. Код включает закомментированный пример (console_handler.SetFormat("[{level}] {message}");), показывающий, как при необходимости можно настроить формат вывода после создания. Затем обработчик добавляется в основной регистратор: if(!g_logger.AddHandler(console_handler))
Если добавление обработчика не удается (возвращает false), выводится предупреждение, а созданный объект обработчика удаляется с помощью delete для предотвращения утечек памяти. Проверка ошибок также выполняется при первоначальном создании (new) обработчика.
Аналогичным образом создается FileLogHandler:// 2. File Handler string log_prefix = MQL5InfoString(MQL5_PROGRAM_NAME) + "_" + IntegerToString(MagicNumber); FileLogHandler *file_handler = new FileLogHandler("MQL5/Logs/EA_Logs", // Directory relative to MQL5/Files log_prefix, // File name prefix FileLogLevel, // Minimum level to log to file "[{time}] {level} ({origin}): {message}", // Format 2048, // Max file size in KB (e.g., 2MB) 10); // Max number of log files to keep
Префикс имени файла создается с использованием имени советника и магического номера для уникальной идентификации. Конструктор FileLogHandler вызывается с несколькими аргументами: путь к каталогу ("MQL5/Logs/EA_Logs", относительно каталога MQL5/Files терминала), сгенерированный префикс, минимальный уровень из входного параметра FileLogLevel, пользовательская строка формата, максимальный размер файла в КБ (2048 КБ = 2 МБ) и максимальное количество сохраняемых файлов журнала (10). Как и консольный обработчик, он добавляется в регистратор с помощью g_logger.AddHandler() с аналогичной обработкой ошибок и очисткой (delete) в случае неудачи создания или добавления.
После настройки обработчиков советник записывает сообщения для подтверждения инициализации:g_logger.Info(__FUNCTION__, \"Expert Advisor initialized successfully.\"); g_logger.Debug(__FUNCTION__, StringFormat(\"Settings: ...\"));
Сообщение уровня Info подтверждает успех. Сообщение уровня Debug записывает ключевые входные параметры с помощью StringFormat. FUNCTION используется в качестве строки источника, автоматически предоставляя имя текущей функции (OnInit). Эти сообщения будут обработаны добавленными обработчиками в соответствии с их настроенными минимальными уровнями.
Наконец, если все инициализации прошли успешно, функция возвращает INIT_SUCCEEDED, сигнализируя терминалу, что советник готов к обработке тиков. Если произошла какая-либо критическая ошибка (например, не удалось получить экземпляр регистратора), возвращается INIT_FAILED.
OnDeinit():
//+------------------------------------------------------------------+ //| Expert deinitialization function | //+------------------------------------------------------------------+ void OnDeinit(const int reason) { //--- Log deinitialization if(CheckPointer(g_logger) != POINTER_INVALID) { string reason_str = "Unknown reason"; switch(reason) { case REASON_ACCOUNT: reason_str = "Account change"; break; case REASON_CHARTCHANGE: reason_str = "Chart symbol or period change"; break; case REASON_CHARTCLOSE: reason_str = "Chart closed"; break; case REASON_PARAMETERS: reason_str = "Input parameters changed"; break; case REASON_RECOMPILE: reason_str = "Recompiled"; break; case REASON_REMOVE: reason_str = "EA removed from chart"; break; case REASON_TEMPLATE: reason_str = "Template applied"; break; case REASON_CLOSE: reason_str = "Terminal closed"; break; } g_logger.Info(__FUNCTION__, "Expert Advisor shutting down. Reason: " + reason_str + " (" + IntegerToString(reason) + ")"); // Release the logger instance (this calls Shutdown() on all handlers) CLogger::Release(); g_logger = NULL; // Set pointer to NULL after release } else { Print("Logger instance was already invalid during Deinit."); } //--- Print to standard log just in case logger failed Print(MQL5InfoString(MQL5_PROGRAM_NAME) + ": Deinitialized. Код причины: " + IntegerToString(reason)); }
В LoggingExampleEA.mq5 функция OnDeinit фокусируется на корректном завершении работы фреймворка логирования:
if(CheckPointer(g_logger) != POINTER_INVALID)
Функция сначала проверяет, действителен ли еще глобальный указатель на регистратор g_logger. Это предотвращает ошибки, если OnDeinit вызывается после того, как регистратор уже был освобожден, или если инициализация не удалась.
Внутри блока if код с помощью оператора switch определяет удобочитаемую строку, соответствующую коду причины, переданному в OnDeinit. Это предоставляет контекст о том, почему советник останавливается. Затем с помощью g_logger.Info() записывается информационное сообщение, включающее определенную строку причины и исходный код причины.
string reason_str = "Неизвестная причина"; switch(reason) { case REASON_ACCOUNT: reason_str = "Смена счета"; break; case REASON_CHARTCHANGE: reason_str = "Смена символа или периода графика"; break; ... ... case REASON_CLOSE: reason_str = "Терминал закрыт"; break; } g_logger.Info(__FUNCTION__, "Завершение работы советника. Причина: " + reason_str + " (" + IntegerToString(reason) + ")");
Это гарантирует, что последние моменты работы советника, включая причину остановки, будут записаны в журналы (как в консоль, так и в файл, в зависимости от настроенных уровней).
Это самый важный шаг для очистки регистратора:
CLogger::Release();
Вызов статического метода Release() класса CLogger инициирует удаление экземпляра-одиночки регистратора. В процессе своего разрушения деструктор CLogger выполняет итерацию по всем добавленным обработчикам (в данном случае консольному и файловому), вызывает их соответствующие методы Shutdown() (которые для FileLogHandler включают закрытие открытого файла журнала), а затем удаляет сами объекты обработчиков. Это гарантирует правильное освобождение всех ресурсов и корректное закрытие файлов.
Обнуление глобального указателя:
g_logger = NULL;
После освобождения экземпляра глобальный указатель g_logger явно устанавливается обратно в NULL. Это хорошая практика, указывающая на то, что указатель больше не указывает на действительный объект.
Блок else обрабатывает случай, когда g_logger уже был недействителен на момент вызова OnDeinit, выводя сообщение в стандартный журнал экспертов. Кроме того, финальный оператор Print вне логики регистратора гарантирует, что сообщение о деинициализации всегда будет записано в стандартный журнал, даже если пользовательский регистратор полностью не сработал.
Эта реализация демонстрирует правильную процедуру завершения работы пользовательского фреймворка логирования, гарантируя, что файлы журнала будут правильно закрыты, а ресурсы освобождены при завершении работы советника.
OnTick():
//+------------------------------------------------------------------+ //| Экспертная функция тика | //+------------------------------------------------------------------+ void OnTick() { //--- Проверьте корректность регистратора if(CheckPointer(g_logger) == POINTER_INVALID) { // Попытка повторно инициализировать регистратор, если он неожиданно стал недействительным // Это защитное программирование, в идеале этого не должно происходить, если OnInit выполнился успешно. Print("Ошибка: Экземпляр регистратора недействителен в OnTick! Попытка повторной инициализации..."); if(OnInit() != INIT_SUCCEEDED) { Print("Critical Error: Failed to re-initialize logger in OnTick. Stopping EA."); ExpertRemove(); // Stop the EA return; } } //--- Log tick arrival MqlTick latest_tick; if(SymbolInfoTick(_Symbol, latest_tick)) { g_logger.Debug(__FUNCTION__, StringFormat("New Tick: Time=%s, Bid=%.5f, Ask=%.5f, Volume=%d", TimeToString(latest_tick.time, TIME_DATE|TIME_SECONDS), latest_tick.bid, latest_tick.ask, (int)latest_tick.volume_real)); } else { g_logger.Warn(__FUNCTION__, "Failed to get latest tick info. Error: " + IntegerToString(GetLastError())); } //--- Example Logic: Check for a simple crossover // Note: Use more robust indicator handling in a real EA double ma_fast[], ma_slow[]; int copied_fast = CopyBuffer(iMA(_Symbol, _Period, 10, 0, MODE_SMA, PRICE_CLOSE), 0, 0, 3, ma_fast); int copied_slow = CopyBuffer(iMA(_Symbol, _Period, 50, 0, MODE_SMA, PRICE_CLOSE), 0, 0, 3, ma_slow); if(copied_fast < 3 || copied_slow < 3) { g_logger.Warn(__FUNCTION__, "Failed to copy enough indicator data."); return; // Not enough data yet } // ArraySetAsSeries might be needed depending on how you access indices // ArraySetAsSeries(ma_fast, true); // ArraySetAsSeries(ma_slow, true); bool cross_up = ma_fast[1] > ma_slow[1] && ma_fast[2] <= ma_slow[2]; bool cross_down = ma_fast[1] < ma_slow[1] && ma_fast[2] >= ma_slow[2]; if(cross_up) { g_logger.Info(__FUNCTION__, "MA Cross Up detected. Потенциальный сигнал на покупку."); // --- Добавьте торговую логику здесь --- // Пример: SendBuyOrder(); } else if(cross_down) { g_logger.Info(__FUNCTION__, "Обнаружено пересечение MA вниз. Потенциальный сигнал на продажу."); // --- Добавьте торговую логику здесь --- // Пример: SendSellOrder(); } // Периодическое логирование информации о счете static datetime last_account_log = 0; if(TimeCurrent() - last_account_log >= 3600) // Log every hour { g_logger.Info(__FUNCTION__, StringFormat("Обновление счета: Баланс=%.2f, Средства=%.2f, Маржа=%.2f, Свободная маржа=%.2f", AccountInfoDouble(ACCOUNT_BALANCE), AccountInfoDouble(ACCOUNT_EQUITY), AccountInfoDouble(ACCOUNT_MARGIN), AccountInfoDouble(ACCOUNT_MARGIN_FREE))); last_account_log = TimeCurrent(); } }
Детальный разбор...
//--- Проверьте корректность регистратора if(CheckPointer(g_logger) == POINTER_INVALID) { // Попытка повторно инициализировать регистратор, если он неожиданно стал недействительным // Это защитное программирование, в идеале этого не должно происходить, если OnInit выполнился успешно.. Print("Ошибка: Экземпляр регистратора недействителен в OnTick! Попытка повторной инициализации..."); if(OnInit() != INIT_SUCCEEDED) { <s16>Print</s16>(<s17>"Критическая ошибка: Не удалось повторно инициализировать регистратор в OnTick. Остановка советника." ); ExpertRemove(); // Stop the EA return; } }
Аналогично OnDeinit, функция начинается с проверки корректности указателя g_logger с помощью CheckPointer. В качестве защитной меры, если обнаруживается, что регистратор недействителен (что в идеале не должно происходить после успешного OnInit), предпринимается попытка повторно инициализировать регистратор путем повторного вызова OnInit(). Если повторная инициализация не удалась, выводится критическая ошибка с использованием стандартной функции Print, и советник останавливается с помощью ExpertRemove().
Далее, советник пытается получить информацию о последнем тике с помощью SymbolInfoTick().//--- Log tick arrival MqlTick latest_tick; if(SymbolInfoTick(_Symbol, latest_tick)) { g_logger.Debug(__FUNCTION__, StringFormat("New Tick: Time=%s, Bid=%.5f, Ask=%.5f, Volume=%d", TimeToString(latest_tick.time, TIME_DATE|TIME_SECONDS), latest_tick.bid, latest_tick.ask, (int)latest_tick.volume_real)); } else { g_logger.Warn(__FUNCTION__, "Failed to get latest tick info. Error: " + IntegerToString(GetLastError())); }
В случае успеха записывается отладочное сообщение (Debug), содержащее временную метку тика, цену Bid, цену Ask и объем, отформатированные с помощью StringFormat. Это обеспечивает детальную трассировку поступающих ценовых данных, что полезно для отладки. Если SymbolInfoTick завершается неудачей, записывается предупреждающее сообщение (Warn), включающее код ошибки, полученный через GetLastError().
Код также включает простой пример проверки пересечения скользящих средних (MA)
//--- Пример логики: Проверка простого пересечения // Примечание: В реальном советнике используйте более надежную работу с индикаторами double ma_fast[], ma_slow[]; int copied_fast = CopyBuffer(iMA(_Symbol, _Period, 10, 0, MODE_SMA, PRICE_CLOSE), 0, 0, 3, ma_fast); int copied_slow = CopyBuffer(iMA(_Symbol, _Period, 50, 0, MODE_SMA, PRICE_CLOSE), 0, 0, 3, ma_slow); if(copied_fast < 3 || copied_slow < 3) { g_logger.Warn(__FUNCTION__, "Не удалось скопировать достаточно данных индикатора."); return; // Не достаточно данных } // ArraySetAsSeries может потребоваться в зависимости от того, как вы обращаетесь к индексам // ArraySetAsSeries(ma_fast, true); // ArraySetAsSeries(ma_slow, true); bool cross_up = ma_fast[1] > ma_slow[1] && ma_fast[2] <= ma_slow[2]; bool cross_down = ma_fast[1] < ma_slow[1] && ma_fast[2] >= ma_slow[2]; if(cross_up) { g_logger.Info(__FUNCTION__, "MA Cross Up detected. Потенциальный сигнал на покупку."</s87>); // --- Добавьте торговую логику здесь ---</s88> // Пример: SendBuyOrder();</s89> } <s90>else</s90> <s91>if</s91>(cross_down) { g_logger.Info(<s92>__FUNCTION__, "Обнаружено пересечение MA вниз. Потенциальный сигнал на продажу."); // --- Добавьте торговую логику здесь --- // Пример: SendSellOrder(); }
Сначала предпринимается попытка скопировать данные с двух индикаторов iMA. Если скопировано недостаточно данных, записывается предупреждающее сообщение (Warn), и функция завершается. Если данные доступны, проверяется условие пересечения между быстрой и медленной скользящими средними на двух предыдущих барах. При обнаружении пересечения (cross_up или cross_down) записывается информационное сообщение (Info), указывающее на потенциальный торговый сигнал. Это демонстрирует логирование значимых событий в рамках торговой стратегии.
Наконец, мы записываем информацию периодически, а не на каждом тике:
// Делайте периодическое логирование информации о счете static datetime last_account_log = 0; if(TimeCurrent() - last_account_log >= 3600) // Записывайте сведения каждый час { g_logger.Info(__FUNCTION__, StringFormat("Обновление счета: Баланс=%.2f, Средства=%.2f, Маржа=%.2f, Свободная маржа=%.2f", AccountInfoDouble(ACCOUNT_BALANCE), AccountInfoDouble(ACCOUNT_EQUITY), AccountInfoDouble(ACCOUNT_MARGIN), AccountInfoDouble(ACCOUNT_MARGIN_FREE))); last_account_log = TimeCurrent(); }
Статическая переменная last_account_log отслеживает время последнего логирования информации о счете. Код проверяет, превышает ли текущее время (TimeCurrent()) время последнего логирования как минимум на 3600 секунд (1 час). Если да, записывается информационное сообщение, содержащее текущий баланс счета, средства, маржу и свободную маржу, после чего обновляется last_account_log. Это предотвращает переполнение журналов повторяющейся информацией, одновременно обеспечивая регулярное обновление статуса.
В целом, функция OnTick демонстрирует, как использовать регистратор для различных целей во время работы советника: детальная отладка (Debug для тиков), предупреждения о потенциальных проблемах (Warn при неудачном копировании данных), информационные сообщения о значимых событиях (Info о сигналах) и периодическое обновление статуса (Info о состоянии счета).
OnChartEvent():
Функция OnChartEvent() - это обработчик событий MQL5, предназначенный для обработки различных событий, происходящих непосредственно на графике, на котором работает советник. Эти события могут включать в себя взаимодействия с пользователем, такие как нажатие клавиш или движение мыши, щелчки по графическим объектам, а также пользовательские события, генерируемые экспертом или другими MQL5-программами.
//+------------------------------------------------------------------+ //| ChartEvent function | //+------------------------------------------------------------------+ void OnChartEvent(const int id, const long &lparam, const double &dparam, const string &sparam) { //--- Ensure logger is valid if(CheckPointer(g_logger) == POINTER_INVALID) return; //--- Log chart events string event_name = "Unknown Chart Event"; switch(id) { case CHARTEVENT_KEYDOWN: event_name = "KeyDown"; break; case CHARTEVENT_MOUSE_MOVE: event_name = "MouseMove"; break; // Add other CHARTEVENT cases as needed case CHARTEVENT_OBJECT_CLICK: event_name = "ObjectClick"; break; case CHARTEVENT_CUSTOM+1: event_name = "CustomEvent_1"; break; // Example custom event } g_logger.Debug(__FUNCTION__, StringFormat("Chart Event: ID=%d (%s), lparam=%d, dparam=%.5f, sparam='%s'", id, event_name, lparam, dparam, sparam)); } //+------------------------------------------------------------------+
Как и в OnTick и OnDeinit, функция начинается с проверки корректности глобального указателя на регистратор g_logger:
if(CheckPointer(g_logger) == POINTER_INVALID) return;
Если регистратор недействителен, функция просто завершается, предотвращая дальнейшую обработку или потенциальные ошибки.
Основная часть функции определяет тип события и записывает его детали:
//--- Log chart events string event_name = "Unknown Chart Event"; switch(id) { case CHARTEVENT_KEYDOWN: event_name = "KeyDown"; break; case CHARTEVENT_MOUSE_MOVE: event_name = "MouseMove"; break; // Add other CHARTEVENT cases as needed case CHARTEVENT_OBJECT_CLICK: event_name = "ObjectClick"; break; case CHARTEVENT_CUSTOM+1: event_name = "CustomEvent_1"; break; // Example custom event } g_logger.Debug(__FUNCTION__, StringFormat("Chart Event: ID=%d (%s), lparam=%d, dparam=%.5f, sparam='%s'", id, event_name, lparam, dparam, sparam));
Оператор switch преобразует каждый входящий идентификатор события в понятное человеку имя event_name, такое как CHARTEVENT_KEYDOWN, CHARTEVENT_MOUSE_MOVE или CHARTEVENT_OBJECT_CLICK. Он даже показывает, как реагировать на определенное пользователем событие (CHARTEVENT_CUSTOM + 1).
Затем мы выводим сообщение уровня Debug с помощью g_logger.Debug(). Эта запись фиксирует идентификатор события, преобразованное имя события и значения параметров (lparam, dparam, sparam), отформатированные через StringFormat. Хранение этой информации на уровне Debug неоценимо во время разработки и тестирования, позволяя отслеживать взаимодействия с графиком и следить за потоками пользовательских событий в вашем приложении.
Преимущества пользовательского фреймворка логирования
Наша специально разработанная система логирования предоставляет несколько улучшений по сравнению с базовой функцией Print():
- Фильтрация по серьезности: Просматривайте только те сообщения, которые важны, ранжированные по приоритету.
- Множественные выходы: Отправляйте логи одновременно в консоль, файлы или другие места назначения.
- Богатый контекст: Временные метки, источник и детали советника добавляются автоматически.
- Гибкое форматирование: Настраивайте макеты сообщений в соответствии с вашими предпочтениями чтения.
- Ротация файлов: Предотвращайте бесконечный рост файлов журналов.
- Централизованное управление: Включайте или отключайте логирование глобально или для отдельных обработчиков.
Эти возможности делают отладку сложных торговых систем гораздо более эффективной. Вы можете быстро выявлять проблемы, наблюдать за поведением с течением времени и сосредотачиваться на данных, которые действительно важны.
Заключение
После внедрения этого пользовательского фреймворка логирования вы можете отказаться от разрозненных операторов Print() и вступить в мир, где ваш код говорит понятными, насыщенными контекстом и полностью настраиваемыми сообщениями. Критические ошибки выделяются, исчерпывающие трассировки готовы для последующего анализа, а файлы журналов остаются в порядке. Что еще лучше, система подстраивается под ваши привычки: меняйте обработчики, изменяйте форматы или регулируйте детализацию когда угодно. Следующая статья добавит инструменты профилирования и модульного тестирования, чтобы вы могли обнаружить проблемы с производительностью и логические ошибки задолго до того, как они появятся на живом графике. Вот что значит настоящее мастерство MQL5.
И помните, это только первый этап путешествия. У нас еще в планах продвинутые методы отладки, пользовательские профилировщики, надежная среда модульного тестирования и автоматизированные проверки качества кода. К концу серии вы замените реактивную охоту на ошибки дисциплинированной, проактивной процедурой обеспечения качества.
А пока удачной торговли и удачного программирования!
Обзор файлов
| Название файла | Описание файла |
|---|---|
| LogLevels.mqh | Определяет перечисление LogLevel со значениями серьезности от DEBUG до OFF, используемыми во всем фреймворке. |
| ILogHandler.mqh | Объявляет интерфейс ILogHandler (Setup/Log/Shutdown), который реализуют все конкретные классы вывода логов. |
| ConsoleLogHandler.mqh | Реализует ILogHandler для вывода отформатированных сообщений журнала на вкладку "Эксперты" MetaTrader с фильтрацией по уровню. |
| FileLogHandler.mqh | Реализует ILogHandler для записи логов в файлы с ежедневной ротацией и ограничением по размеру, сохраняя настраиваемую историю файлов. |
| CLogger.mqh | Регистратор-одиночка, который хранит обработчики, применяет глобальную фильтрацию по серьезности и предлагает удобные методы логирования. |
| LoggingExampleEA.mq5 | Пример советника, показывающий, как настроить, использовать и завершить работу пользовательского фреймворка логирования на практике. |
Перевод с английского произведен MetaQuotes Ltd.
Оригинальная статья: https://www.mql5.com/en/articles/17933
Предупреждение: все права на данные материалы принадлежат MetaQuotes Ltd. Полная или частичная перепечатка запрещена.
Данная статья написана пользователем сайта и отражает его личную точку зрения. Компания MetaQuotes Ltd не несет ответственности за достоверность представленной информации, а также за возможные последствия использования описанных решений, стратегий или рекомендаций.
От начального до среднего уровня: Struct (VII)
Трейдинг с экономическим календарем MQL5 (Часть 9): Расширение интерактивности с новостями через динамический скроллбар и улучшенное отображение
Автоматизация индикатора настроений рынка (индикатора сентимента)
Торговые инструменты на MQL5 (Часть 5): Создание бегущей тикерной строки для мониторинга символов в реальном времени
- Бесплатные приложения для трейдинга
- 8 000+ сигналов для копирования
- Экономические новости для анализа финансовых рынков
Вы принимаете политику сайта и условия использования