MQL5开发专属调试与性能分析工具(第一部分):高级日志记录·进阶篇
(2/3)· 当Print()在实盘最疯的行情里淹没关键警报,你需要一套带级别和上下文的专属日志
在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 工程,把这两个片段塞进同一命名空间,就能先跑通级别显示,再补文件写入。
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。
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 目录权限,避免静默丢日志。
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 抓某次黄金跳空漏单,参数文件改完不用重编译。
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.1、log.2 这类轮转文件,就能验证闭环没漏。
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 返回的是服务器时间,零点的换算直接决定文件切分是否跟你的复盘日界对齐,做黄金或外汇日志时这点容易踩坑。
GenerateFileName 用 StringFormat("%04d%02d%02d") 把年月日压成 20240531 这种定长串,前缀加日期加 .log,每天一个文件。若 FileOpen 返回 INVALID_HANDLE,Print 出错误码后直接返 false,调用方得自己决定要不要降级到内存缓冲。外汇与贵金属波动剧烈,这类本地日志只辅助排查,不构成任何方向判断。
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">//+------------------------------------------------------------------+