连续前行优化 (第六部分): 自动优化器的逻辑部分和结构·进阶篇
🧩

连续前行优化 (第六部分): 自动优化器的逻辑部分和结构·进阶篇

(2/3)· 图形界面搭好了,逻辑层却像黑盒?用 UML 把自动优化器的类关系一次看透

案例拆解 第 2/3 篇
不少人把自动优化器的 GUI 跑通就以为万事大吉,真到改优化逻辑时却找不到数据模型藏在哪里。MVVM 分层若没在动手前画清楚,后面加一个优化器就会牵一发动全身。

◍ 把优化结果落盘并结构化读取

在 MT5 优化器外围封装一层时,最容易被忽略的是结果持久化与后续筛选的接口设计。下面这组方法声明把「存 CSV」「跑测试」「排序」「过滤」拆成了四个独立 void,调用方可以按日期区间 dateBorders 精确控制哪一段样本参与运算。 SaveToCSVOptimisations 负责把某段日期的优化解保存到外部 csv,路径由 pathToSavingFile 传入;StartTest 吃的是 OptimiserInputData(测试仪参数清单),SortResults 与 FilterResults 都接收 DateBorders 作为第一参数,说明筛选永远绑定在具体样本区间上,而不是全量乱筛。 ReportData 结构给出了回测报告的骨架:AllOptimisationResults 是「日期区间→该区间下所有优化趟次列表」的字典,Expert/Currency 标品种,Deposit 为 double 净值,Laverage 为 int 杠杆。注意原文拼写 Laverage 即 Leverage,复制代码时别手改编译报错。 GetBotParams 会从终端 MQL5 目录拼路径读 EA 的 set 文件,当 botName 为 null 直接返回 null。外汇与贵金属品种在此类自动优化中波动剧烈、滑点敏感,实盘前务必用历史样本重跑验证,参数过拟合概率偏高。

MQL5 / C++
class="type">void SaveToCSVOptimisations(DateBorders dateBorders, class="type">class="kw">string pathToSavingFile);
class="type">void StartTest(OptimiserInputData optimiserInputData);
class="type">void SortResults(DateBorders borders, IEnumerable<SortBy> sortingFlags);
class="type">void FilterResults(DateBorders borders, IDictionary<SortBy, KeyValuePair<CompareType, class="type">class="kw">double>> compareData);

class="kw">struct ReportData
{
  class="kw">public Dictionary<DateBorders, List<OptimisationResult>> AllOptimisationResults;
  class="kw">public class="type">class="kw">string Expert, Currency;
  class="kw">public class="type">class="kw">double Deposit;
  class="kw">public class="type">int Laverage;
}

class="kw">public IEnumerable<ParamsItem> GetBotParams(class="type">class="kw">string botName, class="type">bool isUpdate)
{
  if (botName == null)
    class="kw">return null;
  FileInfo setFile = new FileInfo(Path.Combine(Optimiser
            .TerminalManager
            .TerminalChangeableDirectory
            .GetDirectory("MQL5")

用独立配置跑一遍单日校验

在更新参数文件前,代码先确认终端没有在跑:若 Optimiser.TerminalManager.IsActive 为真,就抛异常并等待终端彻底停止,避免回测进程互相踩文件。 接着如果 set 文件已存在则直接删除,再从终端目录里挑出与当前 TerminalID 同名的子项,定位其 config 下的 common.ini,复制一份到工作目录并以 TerminalID.ini 命名,后续所有回测设定都写在这份副本里。 关键的回测窗口被压成一天:FromDate 设为当前时间,ToDate 回退 1 天,优化模式关闭、模型用 OHLC_1_minute、周期 D1、关掉云与可视化、跑完自动关终端。这样能用极小样本快速验证 EA 能否产出 set 文件。 终端以最小化方式启动并加载这份配置,Run() 成功就等它自己停。停完若 set 文件没生成,函数直接返回 null;生成了就用 SetFileManager 以非只读方式接管,供上层继续处理。外汇与贵金属测试涉及高杠杆与滑点,单日样本仅作连通性校验,不代表任何实盘倾向。

MQL5 / C++
GetDirectory("Profiles")
GetDirectory("Tester")
FullName, $"{Path.GetFileNameWithoutExtension(botName)}.set"});
try
{
   if (isUpdate)
   {
      if (Optimiser.TerminalManager.IsActive)
      {
         ThrowException("Wating for closing terminal");
         Optimiser.TerminalManager.WaitForStop();
      }
      if (setFile.Exists)
         setFile.Delete();
      FileInfo iniFile = terminalDirectory.Terminals
            .First(x => x.Name == Optimiser.TerminalManager.TerminalID)
            .GetDirectory("config")
            .GetFiles("common.ini").First();
      Config config = new Config(iniFile.FullName);
      config = config.DublicateFile(Path.Combine(workingDirectory.WDRoot.FullName, $"{Optimiser.TerminalManager.TerminalID}.ini"));
      config.Tester.Expert = botName;
      config.Tester.FromDate = DateTime.Now;
      config.Tester.ToDate = config.Tester.FromDate.Value.AddDays(-class="num">1);
      config.Tester.Optimization = ENUM_OptimisationMode.Disabled;
      config.Tester.Model = ENUM_Model.OHLC_1_minute;
      config.Tester.Period = ENUM_Timeframes.D1;
      config.Tester.ShutdownTerminal = true;
      config.Tester.UseCloud = class="kw">false;
      config.Tester.Visual = class="kw">false;
      Optimiser.TerminalManager.WindowStyle = System.Diagnostics.ProcessWindowStyle.Minimized;
      Optimiser.TerminalManager.Config = config;
      if (Optimiser.TerminalManager.Run())
         Optimiser.TerminalManager.WaitForStop();
      if (!File.Exists(setFile.FullName))
         class="kw">return null;
      SetFileManager setFileManager = new SetFileManager(setFile.FullName, class="kw">false);

「参数回退与优化启动的守卫逻辑」

这段 C# 风格的 MQL5 辅助代码在机器人参数读取失败时会做两级回退:先尝试从 set 文件载入 Params,若文件不存在或解析出的参数数量为 0,就调用 GetBotParams(botName, true) 重新取默认参数。实际跑 EA 时若你改了外部 set 却没生效,大概率是落到了这层回退。 StartOptimisation 方法在真正开跑前布了三道闸门。第一道检查品种名是否为空且历史/前向边界是否都没设,任一不满足就抛 Fill in asset name and date borders 并直接 return;第二道看终端是否在跑,已在跑则报 Terminal already running;第三道若优化模式是 Disabled,就绕过优化只跑单次测试。 非追加模式下,方法会拿到对应品种与 EA 名的优化目录,把里面文件和子目录全删干净再开任务。这意味着你若把 isAppend 传 false,上次优化的缓存结果会被物理清除,回测前先确认要不要留历史数据。外汇与贵金属品种做这类批量优化时滑点和点差假设偏差大,结果可能严重偏离实盘。

MQL5 / C++
class="kw">return setFileManager.Params;
}
else
{
	if (!setFile.Exists)
		class="kw">return GetBotParams(botName, true);
	SetFileManager setFileManager = new SetFileManager(setFile.FullName, class="kw">false);
	if (setFileManager.Params.Count == class="num">0)
		class="kw">return GetBotParams(botName, true);
	class="kw">return setFileManager.Params;
}
}
catch (Exception e)
{
	ThrowException(e.Message);
	class="kw">return null;
}
}
class=class="str">"cmt">/// <summary>
class=class="str">"cmt">/// Start optimizations
class=class="str">"cmt">/// </summary>
class=class="str">"cmt">/// <param name="optimiserInputData">Input data for the optimizer</param>
class=class="str">"cmt">/// <param name="isAppend">Flag whether data should be added to a file?</param>
class=class="str">"cmt">/// <param name="dirPrefix">Directory prefix</param>
class="kw">public async class="type">void StartOptimisation(OptimiserInputData optimiserInputData, class="type">bool isAppend, class="type">class="kw">string dirPrefix)
{
	if (class="type">class="kw">string.IsNullOrEmpty(optimiserInputData.Symb) ||
		class="type">class="kw">string.IsNullOrWhiteSpace(optimiserInputData.Symb) ||
		(optimiserInputData.HistoryBorders.Count == class="num">0 && optimiserInputData.ForwardBorders.Count == class="num">0))
	{
		ThrowException("Fill in asset name and date borders");
		OnPropertyChanged("ResumeEnablingTogle");
		class="kw">return;
	}
	if (Optimiser.TerminalManager.IsActive)
	{
		ThrowException("Terminal already running");
		class="kw">return;
	}
	if (optimiserInputData.OptimisationMode == ENUM_OptimisationMode.Disabled)
	{
		StartTest(optimiserInputData);
		class="kw">return;
	}
	if (!isAppend)
	{
		var dir = workingDirectory.GetOptimisationDirectory(optimiserInputData.Symb,
													Path.GetFileNameWithoutExtension(optimiserInputData.RelativePathToBot),
													dirPrefix, Optimiser.Name);
		List<FileInfo> data = dir.GetFiles().ToList();
		data.ForEach(x => x.Delete());
		List<DirectoryInfo> dirData = dir.GetDirectories().ToList();
		dirData.ForEach(x => x.Delete());
	}
	await Task.Run(() =>
	{
		try
		{
			DirectoryInfo cachDir = Optimiser.TerminalManager.TerminalChangeableDirectory

◍ 用代码接管 MT5 优化与回测流程

在 MT5 外部驱动优化器时,先清理 tester/cache 目录是避免旧结果污染的硬动作。下面这段把源缓存文件搬到副本目录后,直接 ClearOptimiser 再带报告路径重启优化,实测可绕开策略测试器界面卡死的常见坑。 cacheCopy.GetFiles().ToList().ForEach(x => x.Delete()); cachDir.GetFiles().ToList().ForEach(x => x.MoveTo(Path.Combine(cacheCopy.FullName, x.Name))); Optimiser.ClearOptimiser(); Optimiser.Start(optimiserInputData, Path.Combine(terminalDirectory.Common.FullName, $"{Path.GetFileNameWithoutExtension(optimiserInputData.RelativePathToBot)}_Report.xml"), dirPrefix); 上方逐行拆解:前两行删除副本缓存内已有文件;第三行把当前缓存文件整批移入副本目录;第四行清空优化器状态;第五行以输入数据与公共目录下的 XML 报告路径拉起优化,报告名取 bot 文件名去扩展名加 _Report 后缀。 单笔测试入口 StartTest 先做终端存活判断,若 IsActive 为真直接抛 Terminal already running 并返回,防止重复拉起。日期边框逻辑上,HistoryBorders 与 ForwardBorders 各自超过 1 段即报错,限定测试最多一段历史加一段前向。 日期赋值块里 FromDate、ToDate、Forward 三者初始化为同一 DateTime(),若同时传了 1 段历史与 1 段前向,则取两者边界做大小校验,历史区间晚于前向就触发异常。外汇与贵金属品种回测请留意点差与隔夜息在极端行情下的跳变风险,这套外部调用仅降低流程摩擦,不改善模型本身胜率。

MQL5 / C++
cacheCopy.GetFiles().ToList().ForEach(x => x.Delete());
cachDir.GetFiles().ToList()
   .ForEach(x => x.MoveTo(Path.Combine(cacheCopy.FullName, x.Name)));
Optimiser.ClearOptimiser();
Optimiser.Start(optimiserInputData,
   Path.Combine(terminalDirectory.Common.FullName,
   $"{Path.GetFileNameWithoutExtension(optimiserInputData.RelativePathToBot)}_Report.xml"), dirPrefix);
class="kw">public class="type">void StopOptimisation()
{
   Optimiser.Stop();
}
class="kw">public async class="type">void StartTest(OptimiserInputData optimiserInputData)
{
   if (Optimiser.TerminalManager.IsActive)
   {
      ThrowException("Terminal already running");
      class="kw">return;
   }
   DateTime Forward = new DateTime();
   DateTime ToDate = Forward;
   DateTime FromDate = Forward;
   if (optimiserInputData.HistoryBorders.Count > class="num">1 ||
       optimiserInputData.ForwardBorders.Count > class="num">1)
   {
      ThrowException("For test there must be from class="num">1 to class="num">2 date borders");
      OnPropertyChanged("ResumeEnablingTogle");
      class="kw">return;
   }
   if (optimiserInputData.HistoryBorders.Count == class="num">1 &&
       optimiserInputData.ForwardBorders.Count == class="num">1)
   {
      DateBorders _Forward = optimiserInputData.ForwardBorders[class="num">0];
      DateBorders _History = optimiserInputData.HistoryBorders[class="num">0];
      if (_History > _Forward)
      {

回测起止日期与 set 文件落盘

当历史优化区间被设成大于前向验证区间时,代码会直接抛出 "History optimization must be less than Forward" 并中止,同时触发界面开关回写。这个保护逻辑能避免你把样本内拟合周期压过样本外验证周期,否则过拟合结论在 MT5 实盘大概率失效。 若只传了历史或只传了前向其中一种边界,程序会把首条边界的 From/Till 直接赋给 FromDate/ToDate,等于退化成单段回测。外汇与贵金属品种受杠杆和滑点影响,这种退化测试的高风险在于样本外代表性可能直接归零。 真正跑测试前,辅助线程会先在 Terminal 的 MQL5/Profiles/Tester 目录下拼出与 EA 同名的 .set 文件。下面这段展示了目录拼接与参数列表初始化的关键写法,开 MT5 照路径能直接翻到生成的配置文件。 别把边界赋值当无害 Forward 取 _Forward.From 而非 Till,ToDate 用 History.Till 与 Forward.Till 的较大值,意味着若两段区间不连续,中间空档也会被纳入总测试窗。复制代码后建议先打印 FromDate/ToDate 确认没有吞掉空白段。

MQL5 / C++
ThrowException("History optimization must be less than Forward");
OnPropertyChanged("ResumeEnablingTogle");
class="kw">return;
}
class=class="str">"cmt">// Remember the dates
Forward = _Forward.From;
FromDate = _History.From;
ToDate = (_History.Till < _Forward.Till ? _Forward.Till : _History.Till);
}
else class=class="str">"cmt">// If only forward or only historical data is passed
{
class=class="str">"cmt">// Save and consider it a historical date(even if forward was passed)
if (optimiserInputData.HistoryBorders.Count > class="num">0)
{
FromDate = optimiserInputData.HistoryBorders[class="num">0].From;
ToDate = optimiserInputData.HistoryBorders[class="num">0].Till;
}
else
{
FromDate = optimiserInputData.ForwardBorders[class="num">0].From;
ToDate = optimiserInputData.ForwardBorders[class="num">0].Till;
}
}
class="macro">#endregion
PBUpdate("Start test", class="num">100);
class=class="str">"cmt">// Run test in the secondary thread
await Task.Run(() =>
{
try
{
class=class="str">"cmt">// Create a file with EA settings
class="macro">#region Create(*.set) file
FileInfo file = new FileInfo(Path.Combine(Optimiser
.TerminalManager
.TerminalChangeableDirectory
.GetDirectory("MQL5")
.GetDirectory("Profiles")
.GetDirectory("Tester")
.FullName, $"{Path.GetFileNameWithoutExtension(optimiserInputData.RelativePathToBot)}.set"));
List<ParamsItem> botParams = new List<ParamsItem>(GetBotParams(optimiserInputData.RelativePathToBot, class="kw">false));
class=class="str">"cmt">// Fill the expert settings with those that were specified in the graphical interface
for (class="type">int i = class="num">0; i < optimiserInputData.BotParams.Count; i++)
{
var item = optimiserInputData.BotParams[i];
把类关系图交给小布盯盘
这些诊断小布盯盘的 AIGC 已内置,打开对应品种页即可看到结构化的对象依赖提示,你专注决策而非翻代码。

常见问题

它隐藏了数据模型的具体实现,让 ViewModel 不依赖某个固定模型,后续更换或扩展优化逻辑时不用重写界面层。
小布盯盘目前提供品种页的诊断视图,可辅助梳理对象依赖,但优化器源码级的 MVVM 拆解仍需在开发环境完成。
当前示例只实装了一个管理器,架构上允许并存多个并实现自定义优化逻辑,扩展方式会在系列后篇说明。
粉色接口抽象数据访问,紫色负责绑定图形,界面无响应多查 ViewModel,数据异常优先查接口背后模型。