English Deutsch 日本語
preview
MQL5开发专属调试与性能分析工具(第一部分):高级日志记录

MQL5开发专属调试与性能分析工具(第一部分):高级日志记录

MetaTrader 5积分 |
34 0
Sahil Bagdi
Sahil Bagdi

计划如下:

  1. 概述
  2. 构建自定义日志框架
  3. 使用日志框架
  4. 自定义日志框架的优势
  5. 结论



概述

任何在MQL5中编写过EA、指标或脚本的人,都深知其中的无奈:实盘交易出现异常行为、复杂公式计算出错误结果,或者您的EA在市场行情最火爆时突然卡死。通常的快速修复方法——在代码中散布Print()语句、启动策略测试器并祈祷问题自行暴露——但随着代码库规模扩大便不再奏效。

MQL5带来的调试难题,是普通编程语言所没有的。交易程序需实时运行(因此时间至关重要)、处理真金白银(因此错误代价高昂),并且即使在市场剧烈波动时也必须保持极快的响应速度。MetaEditor内置的工具——逐行调试器、用于基本输出的Print()和Comment()函数,以及高级性能分析器——虽然有用,但功能过于通用。它们根本无法满足您的交易算法所需的精准诊断需求。

因此,构建属于您自己的调试和性能分析工具包将带来革命性的改变。量身定制的工具能够提供标准工具集中缺失的精细分析和自定义工作流程,让您更早发现错误、优化性能并保障代码质量。

本系列将指导您构建这样一套工具包。我们将从基础入手——构建一个比零散的Print()调用强大得多的多功能日志框架——然后逐步添加高级调试器、自定义性能分析器、单元测试框架和静态代码检查工具。到本系列结束时,您将拥有一套完整的工具集,将“救火式”调试转变为前瞻性的质量控制。

每一期内容都注重实践:提供完整的、可直接使用的MQL5示例代码,详细解释其工作原理,并说明选择每个设计的理由。学完本系列后,您将拥有可立即使用的工具,并掌握如何根据自身项目需求调整这些工具的技能。

首先,我们来解决最基本的诊断需求——实时、精准地了解程序的一举一动。让我们开始构建这个自定义的日志框架吧。


构建自定义日志框架

在本章节中,我们将开发一个灵活且强大的日志框架,其功能远超MQL5提供的基础Print()函数。我们的自定义日志记录器将支持多种输出格式、日志严重级别以及内容信息,从而大幅提高复杂交易系统的调试效率。

为何常规的Print()函数不够用

在动手构建新系统之前,先来了解一下为何仅依赖Print()函数无法满足专业项目的需求:

  1. 无严重性分级——所有消息都混在一起,导致关键警报被淹没在日常的琐碎信息中。
  2. 内容信息匮乏——Print()无法告知您触发消息的函数是什么,也无法提供当时应用程序的状态信息。
  3. 输出渠道单一——所有信息都只能输出到“Experts”选项卡;没有内置的路径将日志写入文件或其他目标。
  4. 无法过滤——在生产环境中,您无法只关闭详细的调试日志而不影响您关心的错误日志的输出。
  5. 无结构文本——自由格式的输出难以让工具自动解析。

我们定制的日志框架将逐一解决这些痛点,为调试复杂的交易代码奠定坚实的基础。

日志架构

我们将围绕三个核心组件构建一个清晰、模块化且面向对象的系统:
  1. LogLevels:一个枚举类型,用于定义日志的严重级别(DEBUG、INFO、WARN、ERROR、FATAL)。
  2. ILogHandler:一个接口,允许我们接入不同的日志输出目标,如FileLogHandler(文件日志处理器)或ConsoleLogHandler(控制台日志处理器)。
  3. CLogger:一个单例协调器,负责管理日志处理器并提供日志记录API。

接下来,我们将逐一解析每个组件。

日志级别

首先,我们在LogLevels.mqh中定义严重性级别:

enum LogLevel
{
   LOG_LEVEL_DEBUG = 0, // Detailed information for debugging purposes.
   LOG_LEVEL_INFO  = 1, // General information about the system's operation.
   LOG_LEVEL_WARN  = 2, // Warnings about potential issues that are not critical.
   LOG_LEVEL_ERROR = 3, // Errors that affect parts of the system but allow continuity.
   LOG_LEVEL_FATAL = 4, // Serious problems that interrupt the system's execution.
   LOG_LEVEL_OFF   = 5  // Turn off logging.
};

这些日志级别使我们能够根据消息的重要性对其进行分类,并据此进行过滤。例如,在开发过程中,您可能希望看到所有消息(包括DEBUG级别),但在生产环境中,您可能只希望看到WARN级别及以上的消息。

处理器接口

接下来,我们在ILogHandler.mqh文件中定义一个日志处理器接口:

#property strict

#include "LogLevels.mqh"
#include <Arrays/ArrayObj.mqh> // For managing handlers

//+------------------------------------------------------------------+
//| Interface: ILogHandler                                           |
//| Description: Defines the contract for log handling mechanisms.   |
//|              Each handler is responsible for processing and      |
//|              outputting log messages in a specific way (e.g., to |
//|              console, file, database).                           |
//+------------------------------------------------------------------+
interface ILogHandler
  {
//--- Method to configure the handler with specific settings
   virtual bool      Setup(const string settings="");

//--- Method to process and output a log message
   virtual void      Log(const datetime time, const LogLevel level, const string origin, const string message, const long expert_id=0);

//--- Method to perform any necessary cleanup
   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)、严重性级别(来自LogLevels.mqh的level)、消息的来源或出处(origin)、实际的日志消息文本(message)以及一个可选的智能交易系统ID(expert_id)。每个实现类都将根据其特定目的(例如,打印到控制台、写入文件)定义如何格式化以及将此信息发送到何处。
  • virtual void Shutdown():此方法用于在不再需要日志处理器时执行清理操作,通常在主日志类或应用程序的关闭序列期间执行。可能会使用此方法对已打开的文件句柄实现关闭、释放分配的资源或刷新任何缓冲输出,以确保在终止前保存所有日志。

通过定义这个标准接口,日志框架实现了灵活性和可扩展性。主CLogger类可以管理一组不同的ILogHandler对象,向每个对象发送日志消息,而无需了解每个处理器的具体工作方式。只需创建实现ILogHandler接口的新类,即可添加新的输出目标。

控制台日志处理器

该头文件提供了ConsoleLogHandler类,它是ILogHandler接口的具体实现。其特定目的是将格式化后的日志消息定向到MetaTrader 5平台的"Experts"选项卡,该选项卡在EA或脚本执行期间充当控制台输出区域。

#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方法提供具体实现。该类包含两个私有成员变量:类型为LogLevel的m_min_level,用于存储此处理器记录消息所需的最低严重级别;类型为string的m_format,用于保存格式化输出消息所使用的模板。此外,它还声明了私有辅助方法FormatMessage和LogLevelToString,以及用于实现接口的公有方法,也包括用于设置其私有成员的setter方法。

//+------------------------------------------------------------------+
//| 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对象被销毁时清理资源。在具体实现过程中,该类并未直接管理任何动态分配的资源或打开的句柄,因此析构函数体为空,表示此处理器无需执行特殊的清理操作。

//+------------------------------------------------------------------+
//| Setup                                                            |
//+------------------------------------------------------------------+
bool ConsoleLogHandler::Setup(const string settings="")
  {
   // Settings could be used to parse format or min_level, but we use constructor args for now
   // Example: Parse settings string if needed
   return true;
  }
//+------------------------------------------------------------------+
//| Log                                                              |
//+------------------------------------------------------------------+
void ConsoleLogHandler::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)
     {
      // Format and print the message to the Experts tab
      Print(FormatMessage(time, level, origin, message));
     }
  }
//+------------------------------------------------------------------+
//| Shutdown                                                         |
//+------------------------------------------------------------------+
void ConsoleLogHandler::Shutdown()
  {
   // No specific shutdown actions needed for console logging
   PrintFormat("%s: ConsoleLogHandler shutdown.", __FUNCTION__);
  }

  1. Setup方法(ConsoleLogHandler::Setup):
    此方法实现了ILogHandler接口所要求的Setup函数。尽管该设计用于配置,但当前实现并未使用settings字符串参数,因为主要配置(最低级别和格式)已通过构造函数处理。之后直接返回true,表示构造后该处理器被视为随时准备好使用。

  2. Log方法(ConsoleLogHandler::Log):
    这是控制台日志记录操作的核心实现。当主CLogger调用此方法时,它首先检查传入消息的级别是否大于或等于处理器配置的m_min_level,并且小于LOG_LEVEL_OFF。如果消息通过此过滤条件,该方法将调用私有FormatMessage辅助函数,根据m_format模板和提供的日志详情(时间、级别、来源、消息)创建最终输出的字符串。最后,其使用MQL5内置的Print函数在“Experts”选项卡中显示格式化后的字符串。

  3. 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";
     }
  }
//+------------------------------------------------------------------+
  1. 辅助方法(FormatMessage):
    这个私有辅助函数接收原始日志详情(时间、级别、来源、消息)和处理器的格式字符串(m_format)作为输入。它会将格式字符串中的占位符(如{time}、{level}、{origin}和{message})替换为实际对应的值。它使用TimeToString函数来格式化时间戳,并调用LogLevelToString函数获取严重性级别的字符串表示形式。最终生成全格式化字符串,随后返回给Log方法打印。

  2. 辅助方法(LogLevelToString):
    该私有实用函数将LogLevel枚举值转换为其对应的字符串表示形式(例如,LOG_LEVEL_INFO转换为“INFO”)。其使用switch语句来处理已定义的日志级别,对于任何意外值返回“UNKNOWN”。这为格式化后的日志输出提供了可读性的级别指示符。

  3. 设置方法(SetMinLevel, SetFormat):这些公共方法允许用户在处理器创建后更改其配置。SetMinLevel更新m_min_level成员变量,更改后续日志消息的过滤阈值。SetFormat更新m_format成员变量,更改用于格式化未来日志消息的模板。

文件日志处理器


该头文件包含FileLogHandler类,它是ILogHandler接口的另一个具体实现。此处理器专为持久化日志记录而设计,可将格式化后的日志消息写入文件。与控制台处理器相比,其包含更高级的功能,例如基于日期和大小自动轮转日志文件,以及管理保留的日志文件数量。

#property strict

#include "ILogHandler.mqh"
#include "LogLevels.mqh"

//+------------------------------------------------------------------+
//| Class: FileLogHandler                                            |
//| Description: Implements ILogHandler to output log messages to    |
//|              files with rotation capabilities.                   |
//+------------------------------------------------------------------+
class FileLogHandler : public ILogHandler
  {
private:
   LogLevel          m_min_level;       // Minimum level to log
   string            m_format;          // Log message format string
   string            m_file_path;       // Base path for log files
   string            m_file_prefix;     // Prefix for log file names
   int               m_file_handle;     // Current file handle
   datetime          m_current_day;     // Current day for rotation
   int               m_max_size_kb;     // Maximum file size in KB before rotation
   int               m_max_files;       // Maximum number of log files to keep
   
   //--- 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);
   //--- Helper to create or rotate log file
   bool              EnsureFileOpen();
   //--- Helper to generate file name based on date
   string            GenerateFileName(const datetime time);
   //--- Helper to perform log rotation
   void              RotateLogFiles();
   //--- Helper to check if file size exceeds limit
   bool              IsFileSizeExceeded();
   // Add custom helper function to sort string arrays
   void              SortStringArray(string &arr[]);
   //--- New helper to clean file paths
   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 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 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)。公共方法包括构造函数、析构函数、接口实现方法以及用于配置的设置方法。

//+------------------------------------------------------------------+
//| 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();
  }
  1. 构造函数(FileLogHandler::FileLogHandler):
    构造函数用于初始化FileLogHandler对象。它接收文件路径、前缀、最低日志级别、格式字符串、最大文件大小和最大文件数量等参数,并将这些参数设置到对应的成员变量中。它使用CleanPath辅助函数确保文件路径使用正确的目录分隔符。关键的是,如果指定的日志目录(相对于终端数据路径的m_file_path)尚不存在,构造函数还会尝试使用FolderCreate函数创建该目录,以确保处理器有空间写入文件。
  2. 析构函数(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;
     }
  }
  1. 配置方法(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。

  2. 日志记录方法(FileLogHandler::Log):
    该方法处理核心的文件日志记录逻辑。它首先检查消息的级别是否满足m_min_level的要求。如果满足,则调用EnsureFileOpen方法以确保打开了一个有效的日志文件(必要时处理每日轮转)。如果文件成功打开,则使用FormatMessage方法格式化消息,并使用FileWriteString方法将格式化后的字符串后跟换行符(\r\n)写入文件。然后,它调用FileFlush方法以确保数据立即写入磁盘,这对于在应用程序崩溃时捕获日志非常重要。最后,它使用IsFileSizeExceeded方法检查当前文件大小是否超过m_max_size_kb限制。如果超过限制,则关闭当前文件,触发RotateLogFiles方法管理旧文件,并使用EnsureFileOpen方法重新打开一个新文件。

  3. 关闭方法(FileLogHandler::Shutdown):
    该方法实现了接口中的Shutdown要求。其主要职责是使用FileClose方法关闭当前打开的日志文件句柄(m_file_handle)(如果该句柄有效(!= 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);
   
   // Create a datetime that represents just the current day (time set to 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);
   
   // Check if we need to open a new file (either first time or new day)
   if(m_file_handle == INVALID_HANDLE || m_current_day != current_day)
     {
      // Close existing file if open
      if(m_file_handle != INVALID_HANDLE)
        {
         FileClose(m_file_handle);
         m_file_handle = INVALID_HANDLE;
        }
      
      // Update current day
      m_current_day = current_day;
      
      // Generate new file name
      string file_name = GenerateFileName(current_time);
      
      // Open file for writing (append if exists)
      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;
        }
      
      // Move to end of file for appending
      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)
     {
      // Get current position (file size)
      ulong size = FileSize(m_file_handle);
      
      // Check if size exceeds limit (convert KB to bytes)
      return (size > (ulong)m_max_size_kb * 1024);
     }
   
   return false;
  }
  1. 辅助方法(EnsureFileOpen):
    这一关键辅助方法负责管理日志文件的打开操作以及按日轮转。它会将当前日期(通过TimeCurrent()函数获取)与存储的m_current_day变量进行比较。如果文件句柄无效或日期已变更,它会关闭任何现有的文件句柄,更新m_current_day变量,使用GenerateFileName方法生成一个包含日期的新文件名,并以写入/读取模式(FILE_WRITE | FILE_READ | FILE_TXT)打开这个新文件。它使用FileSeek函数将文件指针移动到文件末尾,确保新日志被追加写入。如果文件成功打开或已经处于打开状态,则返回true;如果打开失败,则返回false。

  2. 辅助方法(GenerateFileName):
    此工具方法根据当前时间生成日志文件的完整路径。它将时间中的日期部分格式化为YYYYMMDD字符串,并将其与配置的m_file_path、m_file_prefix以及.log扩展名组合起来,形成完整的日志文件路径。

  3. 辅助方法(IsFileSizeExceeded)此函数检查当前打开的日志文件(m_file_handle)的大小是否超过了配置的m_max_size_kb限制。它使用FileSize函数获取文件大小,并将其与限制值(转换为字节后)进行比较。如果文件大小超过限制,则返回true,否则返回false。
//+------------------------------------------------------------------+
//| RotateLogFiles                                                   |
//+------------------------------------------------------------------+
void FileLogHandler::RotateLogFiles()
  {
   // Get list of log files
   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++;
      
      // Find all matching files
      while(FileFindNext(search_handle, files[file_count]))
        {
         file_count++;
         ArrayResize(files, file_count + 1);
        }
      
      // Close search handle
      FileFindClose(search_handle);
     }
   
   // Resize array to actual number of found files before sorting
   ArrayResize(files, file_count);
   // Sort the string array using the custom sorter
   SortStringArray(files);
   
   // Delete oldest files if we have too many
   int files_to_delete = file_count - m_max_files + 1; // +1 for the new file we'll create
   
   if(files_to_delete > 0)
     {
      for(int i = 0; i < files_to_delete; i++)
        {
         if(!FileDelete(m_file_path + "\\" + files[i]))
            Print("FileLogHandler: Failed to delete old log file: ", files[i], ", error: ", 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;
           }
        }
     }
  }
//+------------------------------------------------------------------+
//| New implementation: CleanPath                                    |
//+------------------------------------------------------------------+
string FileLogHandler::CleanPath(const string path)
  {
   string result = path;
   // Replace all "/" with "\\"
   StringReplace(result, "/", "\\");
   return result;
  }
//+------------------------------------------------------------------+
  1. 辅助方法(RotateLogFiles):
    该方法实现日志文件保留策略。它使用FileFindFirst和FileFindNext函数查找日志目录中所有符合模式(m_file_prefix_*.log)的文件。将找到的文件名存储于字符串数组中,并使用SortStringArray辅助方法按字母顺序排序(由于文件名中包含日期格式,因此通常与时间顺序一致)。之后,计算超出m_max_files限制的文件数量,并使用FileDelete函数删除最旧的文件(排序列表中最早出现的文件)。

  2. 辅助方法(SortStringArray):
    这是一个简单的冒泡排序实现方法,专门用于对在RotateLogFiles中获取的日志文件名数组进行排序。之所以采用这种方式,是因为MQL5标准库中缺少对字符串数组进行内置排序的函数。

  3. 辅助方法(CleanPath):
    此工具方法确保目录路径使用MQL5文件函数所期望的反斜杠(\)作为分隔符,将输入路径字符串中的任何正斜杠(/)替换为反斜杠。

  4. 设置方法(SetFilePath、SetMinLevel等):
    这些公共方法允许在日志处理器初始创建后修改其配置参数(路径、前缀、级别、格式、大小限制),提供了灵活性。

CLogger

该头文件定义了CLogger类,该类充当整个日志框架的核心协调器。它采用单例设计模式实现,确保在整个应用程序中只有一个日志记录器实例存在。这个单一实例管理所有已注册的日志处理器,并为用户代码提供提交日志消息的主要接口。
#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用于存储使用该日志记录器EA的可选信息,这些信息可以包含在日志消息中,以提供更好的内容。

为强制实施单例模式,该类的构造函数和析构函数均为私有。公共方法则提供对实例的访问、处理器管理、配置以及各种日志记录功能的支持。

//+------------------------------------------------------------------+
//| 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();
  }
  1. 单例模式实现(Instance、Release、私有构造函数):
    单例模式通过静态方法Instance()实现,该方法在首次调用时创建CLogger对象,并在后续调用中返回同一实例。构造函数(CLogger::CLogger)为私有,禁止从类外部直接实例化;它为全局最低日志级别和EA信息初始化默认值。提供静态方法Release(),用于显式删除单例实例并清理资源,通常在应用程序关闭时调用。

  2. 析构函数(CLogger::~CLogger):
    当通过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;
  }
  1. 日志处理器管理(AddHandler、ClearHandlers):
    AddHandler方法允许将新的日志处理器(任何实现了ILogHandler接口的对象)添加到日志记录器的内部列表(m_handlers)中。该方法会检查指针是否有效,调整动态数组的大小,并添加该处理器。ClearHandlers方法会遍历m_handlers数组,对每个有效的处理器调用Shutdown方法,删除处理器对象本身(假设日志记录器拥有这些对象的所有权),最后清空数组。这样做对于正确清理资源至关重要。

  2. 配置(SetGlobalMinLevel、SetExpertInfo):
    这些方法允许自定义日志记录器的行为。SetGlobalMinLevel用于调整全局过滤阈值(m_global_min_level),该阈值会在消息到达处理器之前对所有消息产生影响。SetExpertInfo允许设置EA的magic数字和名称,这样处理器就可以在日志消息中自动包含这些信息,以便更好地识别,特别是在多个EA可能同时记录日志的情况下。
//+------------------------------------------------------------------+
//| 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); }
//+------------------------------------------------------------------+
  1. 核心日志记录方法(Log):
    这是接收日志请求的核心方法。它首先检查消息的级别是否满足m_global_min_level的要求。如果满足条件,会获取当前时间,并构造一个effective_origin字符串,可能会在前面添加配置的m_expert_name。接下来,会遍历m_handlers数组,并调用每个有效处理器的Log方法,传递时间戳、级别、来源、消息和EA的magic数字。这实际上将日志消息分派到所有活动的输出目标。

  2. 便捷日志记录方法(Debug、Info, Warn, Error, Fatal):
    这些公共方法为记录特定严重级别的消息提供了更简单的接口。每种方法(例如Debug、Info)只需使用相应的LogLevel枚举值(LOG_LEVEL_DEBUG、LOG_LEVEL_INFO等)调用主要的Log方法,从而减少了用户应用程序中记录消息所需的代码量。

  3. 格式化日志记录方法(LogFormat、DebugFormat等):
    这些方法提供了另一种记录已格式化消息的方式。LogFormat接受一个预先格式化的消息字符串,并调用主要的Log方法。便捷方法如DebugFormat、InfoFormat等,只需使用适当的严重级别调用LogFormat。如果消息格式化逻辑复杂,且在调用日志记录器之前已在其他地方处理,则这些方法非常有用。

随着CLogger实现的完成,是时候看一下它的实际运行效果。


使用日志框架

本EA作为实际案例,演示如何将自定义的MQL5日志框架(包括CLogger、ILogHandler、ConsoleLogHandler和FileLogHandler)集成并应用于实际场景。其展示了在标准EA结构中,日志组件的设置、配置、运行期间的使用以及清理过程。 

LoggingExampleEA.mq5的初始部分设置了标准EA的属性,并引入了自定义日志框架所需的组件。

设置好属性之后,#include语句对于集成日志功能至关重要。CLogger.mqh引入了主日志记录器类的定义。ConsoleLogHandler.mqh包含了用于向MetaTrader控制台(“Experts”选项卡)记录日志的类。FileLogHandler.mqh包含了负责向文件记录日志的类。这些引入语句使得这些头文件中定义的类和函数可以在本EA中使用。

输入参数(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

本章节定义了用户在将EA附加到图表时可配置的外部参数。这些输入参数允许用户自定义EA的交易行为,更重要的是,自定义其日志记录设置。

  • input int MagicNumber = 654321;:这是EA的一个标准参数,用于标识由该EA特定实例所下的订单。它有助于将该EA的交易与其他EA或手动交易区分开来。
  • input double LotSize = 0.01;:定义EA下单时使用的固定交易量(手数)。
  • input int StopLossPips = 50;:设置订单的止损距离(以点为单位)。
  • input int TakeProfitPips = 100;:设置订单的止盈距离(以点为单位)。
至关重要的一点是,以下输入参数直接控制自定义日志记录框架的行为:
  • input LogLevel ConsoleLogLevel = LOG_LEVEL_INFO;:此参数允许用户选择应在MetaTrader的“Experts”选项卡(控制台)中显示的消息的最低严重级别。它使用在LogLevels.mqh中定义的LogLevel枚举类型。默认情况下,其设置为LOG_LEVEL_INFO,这意味着控制台将显示INFO、WARN、ERROR和FATAL消息,而DEBUG消息将被抑制。
  • input LogLevel FileLogLevel = LOG_LEVEL_DEBUG;:类似地,此输入设置写入日志文件的消息的最低严重级别。同样也使用LogLevel枚举。默认值为LOG_LEVEL_DEBUG,表示所有消息,包括详细的调试信息,都将保存到日志文件中。允许在正常操作期间减少控制台输出的冗余信息,同时保留详细的日志以供后续分析或故障排除。

这些特定于日志记录的输入参数展示了如何轻松地从外部配置日志框架,使用户能够调整日志记录的详细程度,而无需修改EA的代码。

// Global logger pointer (optional, can use CLogger::Instance() directly)
CLogger *g_logger = NULL;

EA声明了一个全局变量:CLogger *g_logger = NULL;:这行代码声明了一个名为g_logger的指针,该指针能够指向CLogger类的对象。其初始化为NULL,意味着它最初不指向任何有效对象。该全局指针旨在通过单例模式(CLogger::Instance())获取的CLogger的单一实例。

虽然可以直接在需要日志记录的任何位置使用静态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()函数对于设置和配置自定义日志记录框架至关重要。OnInit()的第一步是获取日志记录器的单例实例:

g_logger = CLogger::Instance();

此静态方法确保只有一个CLogger对象存在。返回的指针存储在全局变量g_logger 中,以便后续更方便地访问。接下来,使用CheckPointer进行基本的错误检查,以确保成功获取了实例;如果未成功获取,则向标准日志打印一条严重错误信息,并且初始化失败(返回INIT_FAILED)。

g_logger.SetExpertInfo(MagicNumber, MQL5InfoString(MQL5_PROGRAM_NAME));

此行代码使用有关该代码的EA信息来配置日志记录器。其传递了MagicNumber(来自输入参数)和EA的名称(使用MQL5InfoString(MQL5_PROGRAM_NAME) 获取)。处理器(取决于它们的格式字符串)可以自动将这些信息包含在日志消息中,这使得在多个EA同时运行时更容易识别特定EA的日志。

使用new动态创建一个ConsoleLogHandler:

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

日志文件前缀通过结合EA名称和magic数字来构建,以确保标识唯一。调用FileLogHandler构造函数时需传入多个参数:目录路径(相对于终端MQL5/Files目录的"MQL5/Logs/EA_Logs")、生成的前缀、来自FileLogLevel输入参数的最低日志级别、自定义格式字符串、最大文件大小(以KB为单位,2048 KB = 2MB)以及要保留的最大日志文件数量(10个)。与控制台处理器类似,使用g_logger.AddHandler()将其添加到日志记录器中,并采用相似的错误处理机制,如果创建或添加失败,则执行清理操作(使用delete释放资源)。

处理器设置完毕后,EA会记录消息以确认初始化完成:
g_logger.Info(__FUNCTION__, \"Expert Advisor initialized successfully.\");
g_logger.Debug(__FUNCTION__, StringFormat(\"Settings: ...\"));

一条Info级别的消息确认初始化成功。一条Debug级别的消息使用StringFormat记录关键输入参数。__FUNCTION__用作来源字符串,自动提供当前函数(OnInit)的名称。这些消息将由已添加的处理器根据其配置的最低级别进行处理。

最后,如果已成功完成所有初始化,则函数返回INIT_SUCCEEDED,向终端发出信号表明EA已准备好开始处理报价数据。如果发生任何关键错误(如未能获取日志记录器实例),则返回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. Reason code: " + IntegerToString(reason));
  }

在LoggingExampleEA.mq5中,OnDeinit函数聚焦于流畅地关闭日志记录框架:

if(CheckPointer(g_logger) != POINTER_INVALID)

该函数首先检查全局日志记录器指针g_logger是否仍然有效。这样可以防止在日志记录器已被释放或初始化失败后调用OnDeinit时出现错误。

在if代码块内,代码使用switch语句确定与传递给OnDeinit的原因代码对应的可读字符串。提供了关于EA停止原因的内容信息。接下来,使用g_logger.Info()记录一条具备信息量的消息,其中包含确定的原因字符串和原始原因代码。

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_CLOSE: reason_str = "Terminal closed"; break;
        }
      g_logger.Info(__FUNCTION__, "Expert Advisor shutting down. Reason: " + reason_str + " (" + IntegerToString(reason) + ")");

这样确保了EA停止前的最后操作(包括停止原因)会被记录到日志中(具体取决于控制台和文件的配置级别)。

这是日志记录器清理过程中最关键的步骤:

CLogger::Release();

调用CLogger类的静态Release()方法会触发单例日志记录器实例的删除。在销毁过程中,CLogger的析构函数会遍历所有已添加的处理器(本例中为控制台和文件日志处理器),调用它们各自的Shutdown()方法(对于FileLogHandler,涉及关闭已打开的日志文件),然后删除处理器对象本身。这就确保了正确释放所有资源,且正确关闭文件。

将全局指针置空:

g_logger = NULL;

释放实例后,显式将全局指针g_logger重置为NULL。这是一次良好的实践,表明该指针不再指向有效对象。

else代码块处理在g_logger已失效时调用OnDeinit的情况,向标准EA日志打印一条消息。此外,在日志记录器逻辑之外添加一条最终的Print语句,确保即使自定义日志记录器完全失败,也始终会在标准日志中记录一条去初始化消息。

此实现展示了关闭自定义日志记录框架的正确流程,确保在EA终止时正确关闭日志文件,从而释放资源。

OnTick():

//+------------------------------------------------------------------+
//| Expert tick function                                             |
//+------------------------------------------------------------------+
void OnTick()
  {
//--- Ensure logger is valid
   if(CheckPointer(g_logger) == POINTER_INVALID)
     {
      // Attempt to re-initialize logger if it became invalid unexpectedly
      // This is defensive coding, ideally it shouldn't happen if OnInit succeeded.
      Print("Error: Logger instance invalid in OnTick! Attempting re-init...");
      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. Potential Buy Signal.");
      // --- Add trading logic here ---
      // Example: SendBuyOrder();
     }
   else if(cross_down)
     {
      g_logger.Info(__FUNCTION__, "MA Cross Down detected. Potential Sell Signal.");
      // --- Add trading logic here ---
      // Example: SendSellOrder();
     }
     
   // Log account info periodically
   static datetime last_account_log = 0;
   if(TimeCurrent() - last_account_log >= 3600) // Log every hour
     {
      g_logger.Info(__FUNCTION__, StringFormat("Account Update: Balance=%.2f, Equity=%.2f, Margin=%.2f, FreeMargin=%.2f",
                                            AccountInfoDouble(ACCOUNT_BALANCE),
                                            AccountInfoDouble(ACCOUNT_EQUITY),
                                            AccountInfoDouble(ACCOUNT_MARGIN),
                                            AccountInfoDouble(ACCOUNT_MARGIN_FREE)));
      last_account_log = TimeCurrent();
     }
  }

放大...

//--- Ensure logger is valid
   if(CheckPointer(g_logger) == POINTER_INVALID)
     {
      // Attempt to re-initialize logger if it became invalid unexpectedly
      // This is defensive coding, ideally it shouldn't happen if OnInit succeeded.
      Print("Error: Logger instance invalid in OnTick! Attempting re-init...");
      if(OnInit() != INIT_SUCCEEDED)
        {
         Print("Critical Error: Failed to re-initialize logger in OnTick. Stopping EA.");
         ExpertRemove(); // Stop the EA
         return;
        }
     }

与OnDeinit相类似,该函数首先使用CheckPointer检查全局日志记录器指针g_logger是否有效。作为一种防御性措施,如果发现日志记录器无效(理论上在OnInit成功执行后不应发生这种情况),会尝试通过再次调用OnInit()来重新初始化日志记录器。如果重新初始化失败,则使用标准的Print函数记录一条严重错误信息,并通过调用ExpertRemove()停止EA的运行。

此外,EA会尝试使用SymbolInfoTick()获取最新的tick数据信息。
//--- 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()));
     }

如果获取成功,EA会使用StringFormat格式化一条Debug级别的日志消息,记录tick数据的时间戳、买入价、卖出价和交易量。这就为调试提供了详细的价格数据追踪信息。如果SymbolInfoTick()调用失败,则会记录一条Warn级别的日志消息,其中包含通过GetLastError()获取的错误代码。

以下代码还包含了一个简单的示例,用于检测移动平均线(MA)的交叉情况:
//--- 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. Potential Buy Signal.");
      // --- Add trading logic here ---
      // Example: SendBuyOrder();
     }
   else if(cross_down)
     {
      g_logger.Info(__FUNCTION__, "MA Cross Down detected. Potential Sell Signal.");
      // --- Add trading logic here ---
      // Example: SendSellOrder();
     }

其首先尝试从两个iMA指标中复制数据。如果复制的数据不足,会记录一条Warn级别的日志消息,然后直接返回。如果数据可用,会检查在前两根K线上快线和慢线之间是否发生交叉。当检测到交叉(cross_up或cross_down)时,会记录一条Info级别的日志消息,提示潜在的交易信号。这样展示了如何在交易策略中记录重要事件。

最后,我们采用周期性记录信息的方式,而不是在每个tick到来时都记录:

   // Log account info periodically
   static datetime last_account_log = 0;
   if(TimeCurrent() - last_account_log >= 3600) // Log every hour
     {
      g_logger.Info(__FUNCTION__, StringFormat("Account Update: Balance=%.2f, Equity=%.2f, Margin=%.2f, FreeMargin=%.2f",
                                            AccountInfoDouble(ACCOUNT_BALANCE),
                                            AccountInfoDouble(ACCOUNT_EQUITY),
                                            AccountInfoDouble(ACCOUNT_MARGIN),
                                            AccountInfoDouble(ACCOUNT_MARGIN_FREE)));
      last_account_log = TimeCurrent();
     }

一个静态变量last_account_log用于记录上次记录账户信息的时间。代码会检查当前时间(TimeCurrent())是否比上次记录时间至少多出3600 秒(1小时)。如果是,则记录一条包含当前账户余额、净值、保证金和可用保证金的Info级别日志,并更新last_account_log值。这种方式可以避免日志被重复信息淹没,同时仍能提供定期的状态更新。

总体而言,OnTick函数展示了如何在EA运行期间利用日志记录器实现不同的目的:详细调试(Debug级别记录tick数据)、潜在问题警告(Warn级别记录数据复制失败)、重要事件通知(Info级别记录交易信号)和定期状态更新(Info级别记录账户状态)。

OnChartEvent():

OnChartEvent()是MQL5的事件处理器,用于处理在 EA 运行图表上直接发生的各类事件。这些事件可能包括用户交互(如键盘输入、鼠标移动/点击图形对象),或由EA自身或其他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语句将每个传入的事件ID转换为可读的事件名称(如CHARTEVENT_KEYDOWN、CHARTEVENT_MOUSE_MOVE或CHARTEVENT_OBJECT_CLICK)。另外,还演示了如何响应用户自定义信号(CHARTEVENT_CUSTOM + 1)。

接下来,我们使用g_logger.Debug()记录一条Debug级别的日志消息。该消息会记录事件ID、解析后的事件名称,以及通过StringFormat格式化的参数值(lparam、dparam、sparam)。在开发和测试阶段,将这些信息保留在Debug级别非常重要,可帮助您追踪图表交互并跟踪应用程序中的自定义事件流。



自定义日志框架的优势

与基本的Print()函数相比,我们量身定制的日志系统包含以下多项改进:

  • 严重性过滤: 仅查看按优先级排序的重要消息。
  • 多输出支持:同时将日志发送到控制台、文件或其他目标。
  • 丰富的内容信息: 自动添加时间戳、来源和EA详细信息。
  • 灵活的格式化:调整消息布局以适应您的阅读偏好。
  • 文件轮转:防止日志文件无限增长。
  • 集中控制:全局或针对单个处理程序开启或关闭日志记录。

这些功能使调试复杂的交易系统变得更加高效。您可以快速定位问题、观察长期行为,并专注于真正重要的数据。



结论

一旦搭建完成这套自定义日志框架,您就可以告别随意插入的Print()语句,进入一个代码清晰、内容丰富且完全可定制的消息进行“交流”的世界。关键故障一目了然,详尽的日志记录随时可供事后复盘,而日志文件也能保持整洁有序。更好的一点是,这套系统会贴合您的使用习惯:随时切换输出目标、调整格式,或按需增减日志详细程度。下一篇文章将引入性能分析和单元测试工具,让您在问题出现在实盘图表之前就能发现性能瓶颈和逻辑漏洞。这才是真正的MQL5开发之道。

请记住,现在仅仅是旅程的第一步。我们的规划还包括高级调试技巧、自定义性能分析器、健壮的单元测试框架,以及自动化代码质量扫描。到本系列结束时,您将从被动“打补丁”转向系统化、主动化的质量保障流程。

在此之前,祝您交易顺利,编码愉快!

文件概述:

文件名 文件描述
LogLevels.mqh 定义LogLevel枚举类型,包含从DEBUG到OFF的日志级别值,供整个日志框架使用。
ILogHandler.mqh 声明ILogHandler接口(包含Setup/Log/Shutdown方法),所有具体的日志输出类均需实现该接口。
ConsoleLogHandler.mqh 实现ILogHandler接口,将格式化后的日志消息按级别过滤后输出到MetaTrader的“Experts”标签页。
FileLogHandler.mqh 实现ILogHandler接口,将日志写入按日期轮转或大小限制的文件,并保留可配置数量的历史日志文件。
CLogger.mqh 用于集中管理日志处理器的单例模式日志记录器,应用全局日志级别过滤,并提供便捷的日志记录方法。
LoggingExampleEA.mq5 EA示例,展示如何在实际应用中配置、使用和关闭自定义日志框架。

本文由MetaQuotes Ltd译自英文
原文地址: https://www.mql5.com/en/articles/17933

附加的文件 |
LogLevels.mqh (0.84 KB)
ILogHandler.mqh (1.08 KB)
FileLogHandler.mqh (14.38 KB)
CLogger.mqh (8.51 KB)
交易策略 交易策略
各种交易策略的分类都是任意的,下面这种分类强调从交易的基本概念上分类。
MQL5交易工具(第二部分):为交互式交易助手添加动态视觉反馈 MQL5交易工具(第二部分):为交互式交易助手添加动态视觉反馈
本文通过引入拖拽面板功能和悬停交互效果,对交易助手工具进行全面升级,使界面操作更直观且响应更迅速。我们优化了工具的实时订单验证机制,确保交易参数能根据市场价格动态校准。同时,我们通过回测验证了这些改进的可靠性。
新手在交易中的10个基本错误 新手在交易中的10个基本错误
新手在交易中会犯的10个基本错误: 在市场刚开始时交易, 获利时不适当地仓促, 在损失的时候追加投资, 从最好的仓位开始平仓, 翻本心理, 最优越的仓位, 用永远买进的规则进行交易, 在第一天就平掉获利的仓位,当发出建一个相反的仓位警示时平仓, 犹豫。
MQL5 简介(第 16 部分):利用技术图表形态构建 EA 交易 MQL5 简介(第 16 部分):利用技术图表形态构建 EA 交易
本文向初学者介绍如何构建一个 MQL5 EA 交易,该系统可以识别和交易经典的技术图表形态 —— 头肩顶形态。它涵盖了如何利用价格行为来检测形态,如何在图表上绘制形态,如何设置入场点、止损点和止盈点,以及如何根据形态自动执行交易。