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

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

(2/3)· 当Print()在实盘最疯的行情里淹没关键警报,你需要一套带级别和上下文的专属日志

偏理论 第 2/3 篇

在MQL5里靠Print()满屏撒点调试的人,基本都经历过EA在波动最猛时静默卡死、却翻不出半条有用线索的窘境。通用输出函数没有严重级别、没有函数上下文、也没有过滤通道,代码一大就彻底失控。本篇接上篇基础,继续深挖如何用自定义日志框架夺回诊断主动权。

日志分级与落盘类的骨架

在 MT5 里做一套可复用的日志系统,第一步是把级别映射成可读字符串。下面这段把 DEBUG / INFO / WARN / ERROR / FATAL 五个档位用 switch 转成大写文本,未命中任何档则返回 UNKNOWN,方便在 EA 运行时直接打印到控制台或文件头。 真正写文件的是 FileLogHandler 类,它公开继承 ILogHandler,私有成员里藏着几个关键旋钮:m_min_level 决定哪些级别以上才落盘,m_max_size_kb 控制单文件超过多少 KB 触发轮转,m_max_files 限制保留几个历史文件。外汇与贵金属行情跳动频繁,日志量可能很大,这两个数值不设好容易把终端目录写满。 类里还预留了三个私有辅助函数:FormatMessage 拼时间、级别、来源和正文;LogLevelToString 复用前面的映射;另一个未列完的 helper 负责按天或按大小创建及轮转文件。你打开 MT5 的 MQH 工程,把这两个片段塞进同一命名空间,就能先跑通级别显示,再补文件写入。

MQL5 / C++
class="type">class="kw">string ConsoleLogHandler::LogLevelToString(const LogLevel level)
  {
   class="kw">switch(level)
     {
      case LOG_LEVEL_DEBUG: class="kw">return "DEBUG";
      case LOG_LEVEL_INFO:  class="kw">return "INFO";
      case LOG_LEVEL_WARN:  class="kw">return "WARN";
      case LOG_LEVEL_ERROR: class="kw">return "ERROR";
      case LOG_LEVEL_FATAL: class="kw">return "FATAL";
      class="kw">default:              class="kw">return "UNKNOWN";
     }
  }

class="macro">#class="kw">property strict
class="macro">#include "ILogHandler.mqh"
class="macro">#include "LogLevels.mqh"

class FileLogHandler : class="kw">public ILogHandler
  {
class="kw">private:
   LogLevel      m_min_level;     class=class="str">"cmt">// Minimum level to log
   class="type">class="kw">string        m_format;        class=class="str">"cmt">// Log message format class="type">class="kw">string
   class="type">class="kw">string        m_file_path;     class=class="str">"cmt">// Base path for log files
   class="type">class="kw">string        m_file_prefix;   class=class="str">"cmt">// Prefix for log file names
   class="type">int           m_file_handle;   class=class="str">"cmt">// Current file handle
   class="type">class="kw">datetime      m_current_day;   class=class="str">"cmt">// Current day for rotation
   class="type">int           m_max_size_kb;   class=class="str">"cmt">// Maximum file size in KB before rotation
   class="type">int           m_max_files;     class=class="str">"cmt">// Maximum number of log files to keep

   class=class="str">"cmt">//--- Helper to format the log message
   class="type">class="kw">string        FormatMessage(const class="type">class="kw">datetime time, const LogLevel level, const class="type">class="kw">string origin, const class="type">class="kw">string message);
   class=class="str">"cmt">//--- Helper to get class="type">class="kw">string representation of LogLevel
   class="type">class="kw">string        LogLevelToString(const LogLevel level);
   class=class="str">"cmt">//--- Helper to create or rotate log file

◍ EA 文件日志处理器的接口骨架

把日志落盘做成独立 handler,核心是先定清楚对外暴露哪些能力。下面这段类声明里,私有区放了一堆辅助函数:确保文件打开的 EnsureFileOpen、按日期拼文件名的 GenerateFileName、做轮转的 RotateLogFiles、判断大小的 IsFileSizeExceeded,还有排序字符串数组的 SortStringArray 和清理路径的 CleanPath。 构造函数给了可直接抄的默认值:路径 MQL5\\Logs、前缀 EA_Log、最低级别 LOG_LEVEL_INFO、单行格式 [{time}] {level}: {origin} - {message}、单文件上限 1024 KB、最多保留 5 个文件。也就是说你不传参也能跑,但想控盘就得用后面那排 setter 改路径、级别、格式和容量。 公开区严格按 ILogHandler 的约定实现了 Setup / Log / Shutdown 三个虚函数,EA 主逻辑只管调 Log,落盘细节全封在里头。外汇与贵金属 EA 跑实盘时日志量可能很大,单文件 1 MB、留 5 个的默认设定在高频策略下可能偏紧,建议开 MT5 后按自己 tick 频率调大 max_size_kb。

MQL5 / C++
  class="type">bool                EnsureFileOpen();
  class=class="str">"cmt">//--- Helper to generate file name based on date
  class="type">class="kw">string              GenerateFileName(const class="type">class="kw">datetime time);
  class=class="str">"cmt">//--- Helper to perform log rotation
  class="type">void                RotateLogFiles();
  class=class="str">"cmt">//--- Helper to check if file size exceeds limit
  class="type">bool                IsFileSizeExceeded();
  class=class="str">"cmt">// Add custom helper function to sort class="type">class="kw">string arrays
  class="type">void                SortStringArray(class="type">class="kw">string &arr[]);
  class=class="str">"cmt">//--- New helper to clean file paths
  class="type">class="kw">string CleanPath(const class="type">class="kw">string path);
class="kw">public:
  FileLogHandler(const class="type">class="kw">string file_path="MQL5\\Logs",
                 const class="type">class="kw">string file_prefix="EA_Log",
                 const LogLevel min_level=LOG_LEVEL_INFO,
                 const class="type">class="kw">string format="[{time}] {level}: {origin} - {message}",
                 const class="type">int max_size_kb=class="num">1024,
                 const class="type">int max_files=class="num">5);
  class="kw">virtual ~FileLogHandler();
  class=class="str">"cmt">//--- ILogHandler implementation
  class="kw">virtual class="type">bool        Setup(const class="type">class="kw">string settings="") class="kw">override;
  class="kw">virtual class="type">void        Log(const class="type">class="kw">datetime time, const LogLevel level, const class="type">class="kw">string origin, const class="type">class="kw">string message, const class="type">long expert_id=class="num">0) class="kw">override;
  class="kw">virtual class="type">void        Shutdown() class="kw">override;
  class=class="str">"cmt">//--- Setters
  class="type">void SetFilePath(const class="type">class="kw">string path)      { m_file_path = CleanPath(path); }
  class="type">void                SetMinLevel(const LogLevel level) { m_min_level = level; }
  class="type">void                SetFormat(const class="type">class="kw">string format)      { m_format = format; }
  class="type">void                SetFilePrefix(const class="type">class="kw">string prefix){ m_file_prefix = prefix; }
  class="type">void                SetMaxSizeKB(const class="type">int size)        { m_max_size_kb = size; }
  class="type">void                SetMaxFiles(const class="type">int count)        { m_max_files = count; }
};

「把日志落盘的配置拆开看」

做 EA 调试时,把运行日志写进文件比单纯 Print 到终端更利于复盘,尤其跑贵金属跨周期策略时,终端刷屏会掩盖关键信号。下面这段构造函数定义了文件日志处理器的六个入参:路径、前缀、最低级别、格式串、单文件上限 KB、保留文件数。 max_size_kb 设 2048、max_files 设 10,意味着磁盘占用峰值约 20 MB 就可能触发轮转,外汇与贵金属高频策略若每 tick 写日志,这个容量半天就会被吃满,需要按实盘频率下调级别或放大上限。 Setup 用分号切键值对,例如 "path=MQL5/Logs;prefix=MyEA;min_level=INFO;max_size=2048;max_files=10",再按等号拆 key/value 并去空格。这种解析不依赖外部配置库,复制进 MT5 的 include 即可直接复用。 目录不存在时 FolderCreate 会建好路径,失败仅 Print 错误码而不抛异常,因此实盘前最好手动确认 Log 目录权限,避免静默丢日志。

MQL5 / C++
FileLogHandler::FileLogHandler(const class="type">class="kw">string file_path,
                               const class="type">class="kw">string file_prefix,
                               const LogLevel min_level,
                               const class="type">class="kw">string format,
                               const class="type">int max_size_kb,
                               const class="type">int max_files)
  {
   m_min_level = min_level;        class=class="str">"cmt">// 记录最低日志级别,低于该级不写盘
   m_format = format;              class=class="str">"cmt">// 日志行格式模板
   m_file_path = CleanPath(file_path); class=class="str">"cmt">// 规范化路径分隔符
   m_file_prefix = file_prefix;    class=class="str">"cmt">// 文件名前缀,便于区分 EA
   m_file_handle = INVALID_HANDLE; class=class="str">"cmt">// 文件句柄初始无效
   m_current_day = class="num">0;              class=class="str">"cmt">// 当前日志日期标记,用于按天切分
   m_max_size_kb = max_size_kb;    class=class="str">"cmt">// 单文件大小上限(KB)
   m_max_files = max_files;        class=class="str">"cmt">// 最多保留的文件个数

   class=class="str">"cmt">// Create directory if it doesn&class="macro">#x27;t exist
   if(!FolderCreate(m_file_path))  class=class="str">"cmt">// 尝试创建日志目录
     {
      if(GetLastError() != class="num">0)      class=class="str">"cmt">// 若系统报错
        Print("FileLogHandler: Failed to create directory: ", m_file_path, ", error: ", GetLastError()); class=class="str">"cmt">// 打印错误路径与码
     }
  }
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Destructor                                                       |
class=class="str">"cmt">//+------------------------------------------------------------------+
FileLogHandler::~FileLogHandler()
  {
   Shutdown();                     class=class="str">"cmt">// 析构时关闭句柄释放资源
  }
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Setup                                                            |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">bool FileLogHandler::Setup(const class="type">class="kw">string settings)
  {
   class=class="str">"cmt">// Parse settings if provided
   class=class="str">"cmt">// Format could be: "path=MQL5/Logs;prefix=MyEA;min_level=INFO;max_size=class="num">2048;max_files=class="num">10"
   if(settings != "")              class=class="str">"cmt">// 有传入配置才解析
     {
      class="type">class="kw">string parts[];             class=class="str">"cmt">// 存分号切出的片段
      class="type">int count = StringSplit(settings, &class="macro">#x27;;&class="macro">#x27;, parts); class=class="str">"cmt">// 按分号拆分

      for(class="type">int i = class="num">0; i < count; i++) class=class="str">"cmt">// 遍历每个片段
        {
         class="type">class="kw">string key_value[];      class=class="str">"cmt">// 存等号切出的键值
         if(StringSplit(parts[i], &class="macro">#x27;=&class="macro">#x27;, key_value) == class="num">2) class=class="str">"cmt">// 恰好拆出两项
           {
            class="type">class="kw">string key = key_value[class="num">0];
            StringTrimLeft(key);  class=class="str">"cmt">// 去左空格
            StringTrimRight(key); class=class="str">"cmt">// 去右空格
            class="type">class="kw">string value = key_value[class="num">1];
            StringTrimLeft(value);class=class="str">"cmt">// 去左空格
            StringTrimRight(value);class=class="str">"cmt">// 去右空格

日志处理器的配置解析与落盘逻辑

这段代码片段处理的是文件日志器的参数读取与写盘动作,属于 EA 调试链路里最容易被忽略、却直接决定你能否复盘行情触发点的底层模块。 配置解析部分用 key-value 分支把外部传入的参数映射到内部成员变量:path 决定落盘目录并经过 CleanPath 清洗,prefix 作为文件名前缀,min_level 则将 DEBUG/INFO/WARN/ERROR/FATAL 五个字符串映射为对应的枚举常量,过滤掉低于阈值的噪音。 max_size 与 max_files 都靠 StringToInteger 强转成 int,意味着你在 ini 里填 "1024" 就会被当作 1024KB 轮转上限;若填非数字字符串,转换结果会是 0,日志轮转可能失效。 Log() 方法本身先判断 level >= m_min_level 且不等于 OFF,才允许写入。EnsureFileOpen() 负责 lazy open,FormatMessage 拼好带时间戳和来源的行,FileWriteString 追加 "\r\n",随后立刻 FileFlush——这一步保证 MT5 崩溃前最后一笔报价事件也能留痕,而不是闷在缓冲区丢干净。 在 MT5 里接这套处理器时,建议先把 min_level 设 INFO 跑一周,再切 DEBUG 抓某次黄金跳空漏单,参数文件改完不用重编译。

MQL5 / C++
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 = (class="type">int)StringToInteger(value);
else if(key == "max_files")
   m_max_files = (class="type">int)StringToInteger(value);
   }
  }
 class="kw">return true;
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Log                                                              |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void FileLogHandler::Log(const class="type">class="kw">datetime time, const LogLevel level, const class="type">class="kw">string origin, const class="type">class="kw">string message, const class="type">long expert_id=class="num">0)
  {
  class=class="str">"cmt">// Check if the message level meets the minimum requirement
  if(level >= m_min_level && level < LOG_LEVEL_OFF)
   {
    class=class="str">"cmt">// Ensure file is open and ready for writing
    if(EnsureFileOpen())
     {
      class=class="str">"cmt">// Format the message
      class="type">class="kw">string formatted_message = FormatMessage(time, level, origin, message);
      class=class="str">"cmt">// Write to file
      FileWriteString(m_file_handle, formatted_message + "\r\n");
      class=class="str">"cmt">// Flush to ensure data is written immediately
      FileFlush(m_file_handle);

◍ 日志轮转与格式化落盘的收口逻辑

写文件不能只管开不管关。当 IsFileSizeExceeded() 判定当前日志体积越界,先 FileClose 释放句柄并置 INVALID_HANDLE,再调 RotateLogFiles() 做归档切割,最后 EnsureFileOpen() 重新打开新文件续写,避免单次日志无限膨胀把 MT5 终端目录拖死。 析构场景靠 Shutdown() 兜底:只要句柄不是 INVALID_HANDLE 就关闭并复位,EA 卸载或重编译时不会留僵尸文件锁。 FormatMessage()m_format 字符串里的 {time}{level}{origin}{message} 占位符,逐条 StringReplace 成实际内容;时间走 TimeToString(time, TIME_DATE|TIME_SECONDS) 精确到秒。级别文本由 LogLevelToString() 映射,DEBUG/INFO/WARN/ERROR/FATAL 五档加一个 default 的 UNKNOWN,方便你后续按关键字 grep 报错。 开 MT5 把这套挂到自己的 EA 里,先故意把单文件上限设小(如 64KB),跑一晚看是否自动切出 log.1log.2 这类轮转文件,就能验证闭环没漏。

MQL5 / C++
  class=class="str">"cmt">// Check if rotation is needed
  if(IsFileSizeExceeded())
    {
     FileClose(m_file_handle);
     m_file_handle = INVALID_HANDLE;
     RotateLogFiles();
     EnsureFileOpen();
    }
   }
 }
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Shutdown                                                            |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void FileLogHandler::Shutdown()
  {
   if(m_file_handle != INVALID_HANDLE)
     {
      FileClose(m_file_handle);
      m_file_handle = INVALID_HANDLE;
     }
  }
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| FormatMessage                                                      |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">class="kw">string FileLogHandler::FormatMessage(const class="type">class="kw">datetime time, const LogLevel level, const class="type">class="kw">string origin, const class="type">class="kw">string message)
  {
   class="type">class="kw">string formatted_message = m_format;
   class=class="str">"cmt">// 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);
   class="kw">return formatted_message;
  }
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| LogLevelToString                                                   |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">class="kw">string FileLogHandler::LogLevelToString(const LogLevel level)
  {
   class="kw">switch(level)
     {
      case LOG_LEVEL_DEBUG: class="kw">return "DEBUG";
      case LOG_LEVEL_INFO:  class="kw">return "INFO";
      case LOG_LEVEL_WARN:  class="kw">return "WARN";
      case LOG_LEVEL_ERROR: class="kw">return "ERROR";
      case LOG_LEVEL_FATAL: class="kw">return "FATAL";
      class="kw">default:              class="kw">return "UNKNOWN";
     }
  }
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| EnsureFileOpen                                                     |

「按交易日滚动的日志文件接管逻辑」

把诊断信息落盘时,最怕跨天还往昨天的文件里写。下面这段 C++ 风格封装用 m_current_day 跟当前 00:00:00 的 datetime 做比对,只要句柄无效或日期变了,就关旧文件、拼新名、以追加方式重开。 EnsureFileOpen 里先 TimeToStruct 取本地时分秒,再手工把 hour/min/sec 清零后 StructToTime 得到当天零点。实测在 MT5 终端里,TimeCurrent 返回的是服务器时间,零点的换算直接决定文件切分是否跟你的复盘日界对齐,做黄金或外汇日志时这点容易踩坑。 GenerateFileNameStringFormat("%04d%02d%02d") 把年月日压成 20240531 这种定长串,前缀加日期加 .log,每天一个文件。若 FileOpen 返回 INVALID_HANDLEPrint 出错误码后直接返 false,调用方得自己决定要不要降级到内存缓冲。外汇与贵金属波动剧烈,这类本地日志只辅助排查,不构成任何方向判断。

MQL5 / C++
class="type">bool FileLogHandler::EnsureFileOpen()
  {
   class="type">class="kw">datetime current_time = TimeCurrent();
   class="type">MqlDateTime time_struct;
   TimeToStruct(current_time, time_struct);
   
   class=class="str">"cmt">// Create a class="type">class="kw">datetime that represents just the current day(time set to class="num">00:class="num">00:class="num">00)
   class="type">MqlDateTime day_struct;
   day_struct.year = time_struct.year;
   day_struct.mon = time_struct.mon;
   day_struct.day = time_struct.day;
   day_struct.hour = class="num">0;
   day_struct.min = class="num">0;
   day_struct.sec = class="num">0;
   class="type">class="kw">datetime current_day = StructToTime(day_struct);
   
   class=class="str">"cmt">// 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)
     {
       class=class="str">"cmt">// Close existing file if open
       if(m_file_handle != INVALID_HANDLE)
         {
          FileClose(m_file_handle);
          m_file_handle = INVALID_HANDLE;
         }
       
       class=class="str">"cmt">// Update current day
       m_current_day = current_day;
       
       class=class="str">"cmt">// Generate new file name
       class="type">class="kw">string file_name = GenerateFileName(current_time);
       
       class=class="str">"cmt">// 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());
          class="kw">return false;
         }
       
       class=class="str">"cmt">// Move to end of file for appending
       FileSeek(m_file_handle, class="num">0, SEEK_END);
     }
   
   class="kw">return true;
  }
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| GenerateFileName                                                  |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">class="kw">string FileLogHandler::GenerateFileName(const class="type">class="kw">datetime time)
  {
   class="type">MqlDateTime time_struct;
   TimeToStruct(time, time_struct);
   
   class="type">class="kw">string date_str = StringFormat("%04d%02d%02d",
                                  time_struct.year,
                                  time_struct.mon,
                                  time_struct.day);
   
   class="kw">return m_file_path + "\\" + m_file_prefix + "_" + date_str + ".log";
  }
class=class="str">"cmt">//+------------------------------------------------------------------+
让小布替你跑这套
这些诊断逻辑小布盯盘已内置,打开对应品种页即可看到结构化的运行日志与异常标记,你只需专注策略决策本身。

常见问题

Print()无严重性分级、无触发函数与状态上下文、输出只能去Experts选项卡且无法按环境过滤,代码规模上升后关键警报会被琐碎信息淹没。
可以,小布盯盘内置了结构化诊断视图,能将带级别和上下文的日志映射到品种页,省去自己接文件通道的重复劳动。
常见做法是分DEBUG、INFO、WARN、ERROR、FATAL,生产环境关掉DEBUG只留WARN以上,能在保住关键信号的同时压低噪声。
分析器看耗时分布更强,日志框架胜在实时上下文与可写文件/多通道,两者互补而非替代,本系列后续篇会补性能分析器。