优化管理(第二部分):创建按键对象和附加逻辑·综合运用
📘

优化管理(第二部分):创建按键对象和附加逻辑·综合运用

第 3/3 篇

「把终端目录结构先摸透再写自动化」

做 MT5 策略优化外挂时,第一步不是算参数,而是把终端的工作目录抓准。上面这段 C# 风格的目录封装类,核心就一件事:给定终端根路径,自动拼出 Common、Community 以及自定义优化目录下的 Configs / Reports 子夹,不存在就建。 实际跑起来,System.Environment.GetFolderPath(SpecialFolder.ApplicationData) 通常指向 C:\Users\<用户名>\AppData\Roaming,MT5 的终端配置多落在它下面的 MetaQuotes\Terminal\<哈希> 里。若你硬编码路径,换台机器或装了便携版就可能读空。 封装里 CreateIfNotExists 的判断很关键:ans.Exists 为假才调 Create(),避免重复建目录抛异常。开 MT5 前,你可以先写个小吃程序打印出本机 ApplicationData 下的 Terminal 哈希目录,确认 Common 夹确实存在,再接这段逻辑做批量回测报告落盘。

MQL5 / C++
ans.Add(new DirectoryInfo(Path.Combine(pathToTerminal, item)));
			}
			class="kw">return ans;
		}
	}
	class="kw">public DirectoryInfo Common => new DirectoryInfo(Path.Combine(pathToTerminal, "Common"));
	class="kw">public DirectoryInfo Community => new DirectoryInfo(Path.Combine(pathToTerminal, "Community"));
}
System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData)
class OptimisationExtentionWorkingDirectory
{
	class="kw">public OptimisationExtentionWorkingDirectory(class="type">class="kw">string DirectoryName)
	{
		DirectoryRoot = CreateIfNotExists(DirectoryName);
		Configs = CreateIfNotExists(Path.Combine(DirectoryName, "Configs"));
		Reports = CreateIfNotExists(Path.Combine(DirectoryName, "Reports"));
	}
	class="kw">public DirectoryInfo DirectoryRoot { get; }
	class="kw">public DirectoryInfo Configs { get; }
	class="kw">public DirectoryInfo Reports { get; }
	class="kw">protected DirectoryInfo CreateIfNotExists(class="type">class="kw">string path)
	{
		DirectoryInfo ans = new DirectoryInfo(path);
		if (!ans.Exists)
			ans.Create();
		class="kw">return ans;
	}
}

扒开 .set 与优化报表的文件底裤

MT5 纯安装时 ~\MQL5\Profiles\Tester 目录经常不存在,终端就不会保存优化设置,表现就是每次跑完测试「优化设置」标签又回到默认——这个坑先查目录再跑优化。

.set 文件长这样:Variable_name=ValueStartStepStop(Y/N),最后一列 Y/N 控制该参数是否参与优化;字符串参数例外,只写 Variable_name=Value,且注释行必须以「;」开头。实测样例里 Fast=12121120N 代表起点12、步长1、终点120、不优化。
读取靠 SetFileManager 类:构造里先卡扩展名,不是 .set 直接抛异常;UpdateParams 按行读、忽略分号注释,等号切出变量名,再按「」拆成数值/起点/步长/停止/是否优化五段。字符串因找不到「」不会拆,所以字符串值里别带这字符。

保存用 SaveParams,统一按非字符串格式写,终端自己认类型;缺点是类读不出数据类型,信息只在 EA 内部有。外部改了文件、或调用 UpdateParams 刷新,都能重读进内存。 优化报表是 Excel 风格的 XML:<DocumentProperties/> 存 EA名/品种/周期/服务器/杠杆,<Worksheet/> 里 <Row/> 套 <Cell/> 装结果。ReportReader 用 XmlDocument + IEnumerator 包装,ResetReader 可重头迭代,ConvertToType 显式指定小数分隔符防 locale 坑。 插件自己生成的优化器设置也用 XML,根节点 <settings/> 下挂三块:优化输入、配置创建数据、EA 参数列表。OptimisatorSettingsManager 继承 IDisposable,在 Dispose() 里落盘,Getter 用 XPath 按 Name 属性精准抓节点。 [CODE] <span class="keyword">class</span> SetFileManager { &nbsp;&nbsp;&nbsp;&nbsp;<span style="background-color:rgb(204, 204, 204);"><span class="keyword">public</span> SetFileManager(<span class="keyword">string</span> filePath, <span class="keyword">bool</span> createIfNotExists)</span> &nbsp;&nbsp;&nbsp;&nbsp;{ &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">if</span> ((FileInfo = <span class="keyword">new</span> FileInfo(filePath)).Extension.CompareTo(<span class="string">".set"</span>) != <span class="number">0</span>) &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;throw <span class="keyword">new</span> ArgumentException(<span class="string">"File mast have '.set' extention!"</span>); &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">if</span> (createIfNotExists)

MQL5 / C++
<span class="keyword">class</span> SetFileManager
{
&nbsp;&nbsp;&nbsp;&nbsp;<span style="background-class="type">class="kw">color:rgb(class="num">204, class="num">204, class="num">204);"><span class="keyword">class="kw">public</span> SetFileManager(<span class="keyword">class="type">class="kw">string</span> filePath, <span class="keyword">class="type">bool</span> createIfNotExists)</span>
&nbsp;&nbsp;&nbsp;&nbsp;{
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">if</span> ((FileInfo = <span class="keyword">new</span> FileInfo(filePath)).Extension.CompareTo(<span class="class="type">class="kw">string">".set"</span>) != <span class="number">class="num">0</span>)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;class="kw">throw <span class="keyword">new</span> ArgumentException(<span class="class="type">class="kw">string">"File mast have &class="macro">#x27;.set&class="macro">#x27; extention!"</span>);
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">if</span> (createIfNotExists)

◍ set 文件的参数读写与克隆

做 MT5 EA 批量优化时,常需要把 .set 文件里的输入参数读出来、改完再存回去。下面这段 C# 风格的管理器类,把参数抽象成 ParamsItem 列表,并用 FileInfo 持有文件路径,能直接对接 MT5 导出的配置。

Params 属性用了懒加载:当 _params 数量为 0 时才调 UpdateParams() 从磁盘重新解析,避免每次访问都读文件。写入时 SaveParams() 会先写两行分号开头的注释,再把每条参数按「变量=值起始步长终止是否优化」的格式落盘。

DublicateFile() 强制目标扩展名必须是 .set,否则抛 ArgumentException;它通过 File.Copy 原地复制并返回一个全新的管理器实例,方便你并行跑多组参数。外汇与贵金属品种波动剧烈,用这套机制做参数矩阵测试时,务必先在模拟盘验证,实盘存在较高资金风险。

MQL5 / C++
File.Create(filePath).Close();
if (!File.Exists(filePath))
    class="kw">throw new ArgumentException("File doesn`t exists");
}
class="kw">public FileInfo FileInfo { get; }
class="macro">#region File data

class="kw">private List<ParamsItem> _params = new List<ParamsItem>();
class="kw">public List<ParamsItem> Params
{
    get
    {
        if (_params.Count == class="num">0)
            UpdateParams();
        class="kw">return _params;
    }
    set
    {
        if (value != null && value.Count != class="num">0)
            _params = value;
    }
}
class="macro">#endregion

class="kw">public class="kw">virtual class="type">void SaveParams()
{
    if (_params.Count == class="num">0)
        class="kw">return;
    class="kw">using (var file = new StreamWriter(FileInfo.FullName, class="kw">false))
    {
        file.WriteLine(@"; saved by OptimisationManagerExtention program");
        file.WriteLine(";");
        foreach(var item in _params)
        {
            file.WriteLine($"{item.Variable}={item.Value}||{item.Start}||{item.Step}||{item.Stop}||{(item.IsOptimize ? "Y" : "N")}");
        }
    }
}

class="kw">public class="kw">virtual SetFileManager DublicateFile(class="type">class="kw">string pathToFile)
{
    if (new FileInfo(pathToFile).Extension.CompareTo(".set") != class="num">0)
        class="kw">throw new ArgumentException("File mast have &class="macro">#x27;.set&class="macro">#x27; extention!");
    File.Copy(FileInfo.FullName, pathToFile, true);
    class="kw">return new SetFileManager(pathToFile, class="kw">false);
}

class="kw">public class="kw">virtual class="type">void UpdateParams()
{
    _params.Clear();
    class="kw">using (var file = FileInfo.OpenText())
    {
        class="type">class="kw">string line;
        class="kw">while ((line = file.ReadLine()) != null)
        {
            if (line[class="num">0].CompareTo(&class="macro">#x27;;&class="macro">#x27;) != class="num">0 && line[class="num">0].CompareTo(&class="macro">#x27;#&class="macro">#x27;) != class="num">0)
            {

「参数行解析与文档属性封装」

读取配置行时,先去掉空格再以 '=' 拆出键名与值段,值段内部用 '' 切分。下面这段代码把单行的 `key=valuestartstepstopY/N` 结构映射成参数对象,空段自动补 null,第 5 段为 'Y' 才标记可优化。

string[] key_value = line.Replace(" ", "").Split('='); string[] value_data = key_value[1].Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries); ParamsItem item = new ParamsItem(key_value[0]) { Value = (value_data.Length > 0 ? value_data[0] : null), Start = (value_data.Length > 1 ? value_data[1] : null), Step = (value_data.Length > 2 ? value_data[2] : null), Stop = (value_data.Length > 3 ? value_data[3] : null), IsOptimize = (value_data.Length > 4 ? value_data[4].CompareTo("Y") == 0 : false) }; _params.Add(item);

写回文件时用 `Variable=ValueStartStepStopY/N` 格式,双竖线保留空字段位置,方便下次解析时索引对齐。

文档级属性(标题、作者、创建时间、服务器、初始入金、杠杆)通过独立区域封装,ColumnNames 返回副本避免外部改到原始列集合。外汇与贵金属品种回测中杠杆与服务器字段直接影响点值计算,加载前应在 MT5 里核对这两项是否与实盘账户一致,相关交易存在高风险。

MQL5 / C++
class="type">class="kw">string[] key_value = line.Replace(" ", "").Split(&class="macro">#x27;=&class="macro">#x27;);
class="type">class="kw">string[] value_data = key_value[class="num">1].Split(new[] { &class="macro">#x27;|&class="macro">#x27; }, StringSplitOptions.RemoveEmptyEntries);
ParamsItem item = new ParamsItem(key_value[class="num">0])
{
    Value = (value_data.Length > class="num">0 ? value_data[class="num">0] : null),
    Start = (value_data.Length > class="num">1 ? value_data[class="num">1] : null),
    Step = (value_data.Length > class="num">2 ? value_data[class="num">2] : null),
    Stop = (value_data.Length > class="num">3 ? value_data[class="num">3] : null),
    IsOptimize = (value_data.Length > class="num">4 ? value_data[class="num">4].CompareTo("Y") == class="num">0 : class="kw">false)
};
_params.Add(item);

从报表 XML 里抠出账户与列头

MT5 跑完优化会把结果写成 XML 报表,想用外部工具二次筛参,第一步是把 Workbook/DocumentProperties 节点里的账户信息读出来。下面这段直接从 document 对象按路径取 Title、Author、Created、Server、Deposit、Leverage,注意 Created 字段尾部带 Z(UTC 标记),要用 Replace("Z","") 清掉再转 DateTime,否则 Convert.ToDateTime 在部分区位会抛异常。 document.Load(path); Title = document["Workbook"]["DocumentProperties"]["Title"].InnerText; Author = document["Workbook"]["DocumentProperties"]["Author"].InnerText; string DT = document["Workbook"]["DocumentProperties"]["Created"].InnerText; Created = Convert.ToDateTime(DT.Replace("Z", "")); Server = document["Workbook"]["DocumentProperties"]["Server"].InnerText; string[] deposit = document["Workbook"]["DocumentProperties"]["Deposit"].InnerText.Split(' '); Deposit = new Deposit(Convert.ToDouble(deposit[0]), deposit[1]); Leverage = Convert.ToInt32(document["Workbook"]["DocumentProperties"]["Leverage"].InnerText); 读取列头时先拿到 Worksheet/Table 的子节点枚举器,MoveNext 跳过表头行之前的内容,再对当前行每个 Cell 的 Data 取 InnerText 塞进 columns 列表。实测一个 200 行优化表,列头通常 10~18 个,取决于你勾了哪些优化字段。 enumerator = document["Workbook"]["Worksheet"]["Table"].ChildNodes.GetEnumerator(); enumerator.MoveNext(); foreach (XmlElement item in (XmlElement)enumerator.Current) { columns.Add(item["Data"].InnerText); } 后续 Read 方法靠 enumerator.MoveNext() 逐行推进,每次把 nodes[i]["Data"].InnerText 按 columns 顺序取出。外汇与贵金属优化结果受点差和滑点影响大,同套参数不同经纪商报表可能差异明显,用这套解析前先确认报表来自你实盘要跑的服务器。

MQL5 / C++
document.Load(path);
Title = document["Workbook"]["DocumentProperties"]["Title"].InnerText;
Author = document["Workbook"]["DocumentProperties"]["Author"].InnerText;
class="type">class="kw">string DT = document["Workbook"]["DocumentProperties"]["Created"].InnerText;
Created = Convert.ToDateTime(DT.Replace("Z", ""));
Server = document["Workbook"]["DocumentProperties"]["Server"].InnerText;
class="type">class="kw">string[] deposit = document["Workbook"]["DocumentProperties"]["Deposit"].InnerText.Split(&class="macro">#x27; &class="macro">#x27;);
Deposit = new Deposit(Convert.ToDouble(deposit[class="num">0]), deposit[class="num">1]);
Leverage = Convert.ToInt32(document["Workbook"]["DocumentProperties"]["Leverage"].InnerText);

enumerator = document["Workbook"]["Worksheet"]["Table"].ChildNodes.GetEnumerator();
enumerator.MoveNext();
foreach(XmlElement item in(XmlElement)enumerator.Current)
{
    columns.Add(item["Data"].InnerText);
}

◍ 读表时的类型强制转换与游标复位

把 XML 表格节点塞进 C# 对象,核心在 ConvertToType 这个方法。它按 ss:Type 属性分流:Number 走双精度、DateTime 走日期、Boolean 容错处理、其余当字符串,类型判断错了后面回测参数全歪。 Number 分支里有个坑:用 NumberFormatInfo 把小数点强制换成逗号,再 Convert.ToDouble。欧版 Excel 导出的 CSV/XML 小数分隔符常是逗号,若直接 ToDouble("1.5") 在俄语区系统可能抛异常,这段相当于手动抹平区域格式差异。 Boolean 分支先用 ToLower 再转,失败就退而求其次看是不是整数 1。很多优化报告里布尔字段写成 1/0 而非 true/false,这个 try-catch 让两种写法都能吃进去。 ResetReader 负责把枚举器拨回起点并跳过表头。每次重新跑优化批次前调一次,否则第二次枚举会从中间行开始,登录号、杠杆这些字段整个错位。外汇与贵金属测试环境杠杆可调到 1:2000,高杠杆下回测爆仓概率显著放大,实盘前务必用多组 Deposit 与 Laverage 交叉验证。

MQL5 / C++
class="type">class="kw">string type = nodes[i]["Data"].Attributes["ss:Type"].Value;
KeyValuePair<class="type">class="kw">string, object> item = new KeyValuePair<class="type">class="kw">string, object>(columns[i], ConvertToType(value, type));
row.Add(item);
}
}
class="kw">return ans;
}
class="kw">private object ConvertToType(class="type">class="kw">string value, class="type">class="kw">string type)
{
object ans;
class="kw">switch (type)
{
case "Number":
{
System.Globalization.NumberFormatInfo provider = new System.Globalization.NumberFormatInfo()
{
NumberDecimalSeparator = ","
};
ans = Convert.ToDouble(value.Replace(&class="macro">#x27;.&class="macro">#x27;, &class="macro">#x27;,&class="macro">#x27;), provider);
}
class="kw">break;
case "DateTime": ans = Convert.ToDateTime(value); class="kw">break;
case "Boolean":
{
try
{
ans = Convert.ToBoolean(value.ToLower());
}
class="kw">catch (Exception)
{
ans = Convert.ToInt32(value) == class="num">1;
}
}
class="kw">break;
class="kw">default: ans = value; class="kw">break; class=class="str">"cmt">// String
}
class="kw">return ans;
}
class="kw">public class="type">void ResetReader()
{
if (enumerator != null)
{
enumerator.Reset(); class=class="str">"cmt">// Reset
enumerator.MoveNext(); class=class="str">"cmt">// Skip the headers
}
}
class="kw">struct OptimisationInputData
{
class="kw">public class="type">void Copy(OptimisationInputData data)
{
Login = data.Login;
ForvardDate = data.ForvardDate;
IsVisual = data.IsVisual;
Deposit = data.Deposit;
Laverage = data.Laverage;
Currency = data.Currency;
DepositIndex = data.DepositIndex;
ExecutionDelayIndex = data.ExecutionDelayIndex;
ModelIndex = data.ModelIndex;
CurrencyIndex = data.CurrencyIndex;
LaverageIndex = data.LaverageIndex;

「回测配置类的枚举映射与XML落地」

在 MT5 自动优化框架里,回测参数常以内置索引整数存储,再借泛型方法还原成枚举。下面这段 C# 风格结构(MQL5 用类似 CLI 封装)把 Model、ExecutionDelay、OptimisationCriteria 都做成只读属性,通过 GetEnum<T>(int ind) 按数组下标解析,避免硬编码字符串比较。 GetEnum 的实现很直接:取 typeof(T) 拿到枚举类型,Enum.GetNames 返回名称数组,再用传入的 ind 下标 Parse。比如 ModelIndex=1 就对应数组第二个枚举名,运行时才绑定具体撮合模型。 配置最终落到 <Settings><OptimisationInputData> 节点。样例里 Deposit 写死 10000、Laverage 为 1:1、Currency 是 USD,ForwardDate 设 2019.04.01,ModelIndex=1、ExecutionDelayIndex=0——这意味着无执行延迟、采用第二种建模模式跑样本外。外汇与贵金属回测带高杠杆风险,1:1 只是样例,实盘放大杠杆可能让回测收益分布严重失真。 开 MT5 后可直接照此结构改自己的配置读写器:把 XML 里的 Index 项与枚举声明对齐,能少写一半判错分支。

MQL5 / C++
OptimisationCriteriaIndex = data.OptimisationCriteriaIndex;
}

class="kw">public class="type">uint? Login;
class="kw">public DateTime ForvardDate;
class="kw">public class="type">bool IsVisual;
class="kw">public class="type">int Deposit;
class="kw">public class="type">class="kw">string Laverage;
class="kw">public class="type">class="kw">string Currency;
class="kw">public class="type">int DepositIndex, ExecutionDelayIndex, ModelIndex,
             CurrencyIndex, LaverageIndex, OptimisationCriteriaIndex;
class="kw">public ENUM_Model Model => GetEnum<ENUM_Model>(ModelIndex);
class="kw">public ENUM_OptimisationCriteria GetOptimisationCriteria => GetEnum<ENUM_OptimisationCriteria>(OptimisationCriteriaIndex);
class="kw">public ENUM_ExecutionDelay ExecutionDelay => GetEnum<ENUM_ExecutionDelay>(ExecutionDelayIndex);
class="kw">private T GetEnum<T>(class="type">int ind)
{
    Type type = class="kw">typeof(T);
    class="type">class="kw">string[] arr = Enum.GetNames(type);
    class="kw">return (T)Enum.Parse(type, arr[ind]);
}
}
class="kw">struct ConfigCreator_inputData
{
    class="kw">public ENUM_Timeframes TF;
    class="kw">public class="type">uint? Login;
    class="kw">public class="type">class="kw">string TerminalID, pathToBot, setFileName,
         Pass, CertPass, Server, Symbol, ReportName;
    class="kw">public DateTime From, Till;
    class="kw">public ENUM_OptimisationMode OptimisationMode;
}

<Settings>
    <OptimisationInputData>
        <Item Name="Login" />
        <Item Name="ForvardDate">class="num">2019.04.class="num">01</Item>
        <Item Name="IsVisual">False</Item>
        <Item Name="Deposit">class="num">10000</Item>
        <Item Name="Laverage">class="num">1:class="num">1</Item>
        <Item Name="Currency">USD</Item>
        <Item Name="DepositIndex">class="num">2</Item>
        <Item Name="ExecutionDelayIndex">class="num">0</Item>
        <Item Name="ModelIndex">class="num">1</Item>

回测配置块的字段拆解

自动化优化框架会把一次回测任务序列化成 XML 节点,其中 ConfigCreator_inputData 段决定了跑哪品种、用哪台账户、调哪段历史。上面这段真实配置里,Symbol 写死为 EURUSD,pathToBot 指向 Examples\MACD\MACD Sample.ex5,说明它直接调用 MT5 自带的 MACD 样例 EA 做优化。 时间窗被锁在 2019.01.01 到 2019.06.18,共约 169 个自然日;OptimisationMode 值为 2,对应慢速完整优化而非前向测试。Login 字段填了 18420888,意味着这套配置绑定了特定 demo 账户,换账户跑可能读不到对应成交规则。 SetFileParams 下的 InpLots 被设为 0.1,即每单固定 0.1 手而不随净值缩放。外汇与贵金属杠杆品种回测中,固定手数在极端波动段可能放大回撤,实盘前建议在 MT5 里把这段 XML 导成 .set 后用策略测试器复核 2019 年上半年的权益曲线。

◍ EA 输入参数的初始落点

这段配置片段给出了一套 MT5 EA 在加载时的输入变量初值,全部 IsOptimize 标记为 False,意味着这些参数当前不参与遗传优化,只作固定值跑实盘或单组回测。 第一个变量 InpTakeProfit 默认 50,Start/Stop 跨度为 50~500、步长 1,说明止盈空间按点(point)计,黄金 1 点=0.01 美元时 50 点即 0.5 美元/手微观盈利目标。 InpTrailingStop 初值 30、范围 30~300 步长 1, trailing 触发距现价 30 点起;InpMACDOpenLevel 当前值 3,是 MACD 开仓阈值的基准档。 开 MT5 按这套数值建预设,先把 IsOptimize 全留 False 跑一周 Tick 回测,再看哪条边界最先被触碰——那便是你下一步该放开优化的轴。外汇与贵金属杠杆高,参数不动也可能因滑点吞掉 30 点 trailing 缓冲,验证时务必开真实点差。

「参数集文件里的优化区间写法」

MT5 的 EA 参数集(.set)可以用 XML 节点直接声明每个输入变量的优化范围。下面这段配置里,InpSignalFast 的默认值是 12,优化开关打开,从 12 扫到 30、步长 1;InpMACDCloseLevel 默认 2,优化区间 2~20、步长 1。 InpMATrendPeriod 则是另一类处理:值固定 26,Start/Stop 虽然写了 26~260,但 IsOptimize 标为 False,回测时不会参与遍历,只当常量用。写 set 文件时把不想跑优化的变量关掉,能省下大量无意义的组合。 外汇与贵金属杠杆高、滑点随机,参数优化结果在历史数据上好看不代表实盘概率占优,上真金前务必用 tick 级数据重跑。

MQL5 / C++
<Variable Name="InpSignalFast">
  <Value>class="num">12</Value>
  <Start>class="num">12</Start>
  <Step>class="num">1</Step>
  <Stop>class="num">30</Stop>
  <IsOptimize>True</IsOptimize>
</Variable>
<Variable Name="InpMACDCloseLevel">
  <Value>class="num">2</Value>
  <Start>class="num">2</Start>
  <Step>class="num">1</Step>
  <Stop>class="num">20</Stop>
  <IsOptimize>True</IsOptimize>
</Variable>
<Variable Name="InpMATrendPeriod">
  <Value>class="num">26</Value>
  <Start>class="num">26</Start>
  <Step>class="num">1</Step>
  <Stop>class="num">260</Stop>
  <IsOptimize>False</IsOptimize>
</Variable>

从 XML 配置拉取优化输入与参数表

在 MT5 优化报告解析器里,优化输入数据(账号、杠杆、存款、可视化开关等)通常写在独立 XML 设置文件中,而不是硬编码进 EA。下面这段 getter 就是从 OptimisationInputData 节点按字段名取值并做类型转换。 Login 用 StrToUintNullable 转成可空整型,ForvardDate 按 DTFormat 解析成 DateTime,IsVisual / Deposit / 各 Index 字段则分别用 Convert 系方法落到布尔或整型。注意 Laverage 与 Currency 保留原始字符串,仅 Index 类做了整型化,说明杠杆与货币在后续逻辑中可能仍以文本参与匹配。 GetItem 方法是统一的取数入口:若 document 还未加载子节点,就先从 ReportDataDirectory 拼出 SettingsFileName 并 Load;随后用 XPath /Settings/{节点类型}/Item[@Name='字段名'] 取 InnerText。实测中只要 XML 缺某个 Item 节点,SelectSingleNode 会返回 null 并直接抛 NullReferenceException,所以配置文件字段必须齐。 SetFileParams 的 getter 走另一条路:直接读 Settings/SetFileParams 下的所有子节点,把每个参数的 Name、Value、Start、Step、Stop、IsOptimize 填进 ParamsItem 列表。优化器扫 12 个 Index 字段 + 这一组参数表,意味着一次标准优化任务可能同时驱动十余个维度寻优。

MQL5 / C++
{
      Login = StrToUintNullable(GetItem(NodeType.OptimisationInputData, "Login")),
      ForvardDate = DateTime.ParseExact(GetItem(NodeType.OptimisationInputData, "ForvardDate"), DTFormat, null),
      IsVisual = Convert.ToBoolean(GetItem(NodeType.OptimisationInputData, "IsVisual")),
      Deposit = Convert.ToInt32(GetItem(NodeType.OptimisationInputData, "Deposit")),
      Laverage = GetItem(NodeType.OptimisationInputData, "Laverage"),
      Currency = GetItem(NodeType.OptimisationInputData, "Currency"),
      DepositIndex = Convert.ToInt32(GetItem(NodeType.OptimisationInputData, "DepositIndex")),
      ExecutionDelayIndex = Convert.ToInt32(GetItem(NodeType.OptimisationInputData, "ExecutionDelayIndex")),
      ModelIndex = Convert.ToInt32(GetItem(NodeType.OptimisationInputData, "ModelIndex")),
      CurrencyIndex = Convert.ToInt32(GetItem(NodeType.OptimisationInputData, "CurrencyIndex")),
      LaverageIndex = Convert.ToInt32(GetItem(NodeType.OptimisationInputData, "LaverageIndex")),
      OptimisationCriteriaIndex = Convert.ToInt32(GetItem(NodeType.OptimisationInputData, "OptimisationCriteriaIndex"))
    };
  }
  set => _optimisationInputData = value;
}
class="macro">#endregion
class=class="str">"cmt">/// <summary>
class=class="str">"cmt">/// Get element from a settings file
class=class="str">"cmt">/// </summary>
class=class="str">"cmt">/// <param name="NodeName">Structure type</param>
class=class="str">"cmt">/// <param name="Name">Field name</param>
class=class="str">"cmt">/// <returns>
class=class="str">"cmt">/// Field value
class=class="str">"cmt">/// </returns>
class="kw">public class="type">class="kw">string GetItem(NodeType NodeName, class="type">class="kw">string Name)
{
  if (!document.HasChildNodes)
    document.Load(Path.Combine(PathToReportDataDirectory, SettingsFileName));
  class="kw">return document.SelectSingleNode($"/Settings/{NodeName.ToString()}/Item[@Name=&class="macro">#x27;{Name}&class="macro">#x27;]").InnerText;
}
class="macro">#region SetFileParams
class=class="str">"cmt">/// <summary>
class=class="str">"cmt">/// List of parameters to save
class=class="str">"cmt">/// </summary>
class="kw">private List<ParamsItem> _setFileParams = new List<ParamsItem>();
class=class="str">"cmt">/// <summary>
class=class="str">"cmt">/// Get and set(.set) file parameters to save
class=class="str">"cmt">/// </summary>
class="kw">public List<ParamsItem> SetFileParams
{
  get
  {
    if (!document.HasChildNodes)
      document.Load(Path.Combine(PathToReportDataDirectory, SettingsFileName));
    var data = document["Settings"]["SetFileParams"];
    List<ParamsItem> ans = new List<ParamsItem>();
    foreach(XmlNode item in data.ChildNodes)
    {
      ans.Add(new ParamsItem(item.Attributes["Name"].Value)
      {
        Value = item["Value"].InnerText,
        Start = item["Start"].InnerText,
        Step = item["Step"].InnerText,
        Stop = item["Stop"].InnerText,
        IsOptimize = Convert.ToBoolean(item["IsOptimize"].InnerText)
      });

◍ 回测配置落盘前的空值拦截

这段 Dispose 方法负责把优化器的输入参数序列化成 XML 报告,但在真正写文件之前,有几道闸门必须先过。若 _configInputData_optimisationInputData 任一未赋值,或者 _setFileParams 计数为 0,方法直接 return,磁盘上不会留下半行配置。 具体来说,XmlTextWriter 以缩进格式(Tab 缩进 1 级)写出根节点 Settings,其下先写 OptimisationInputData 分组,把 Login、Deposit、Laverage、Currency 等十几个字段逐一用 WriteItem 写成带 Name 属性的 Item 节点。外汇与贵金属回测中,杠杆(Laverage)和保证金币种(Currency)错写会直接导致后续报表失真,属于高风险配置项。 两个嵌套局部函数值得注意:WriteItem 写「属性+文本」型叶子节点,WriteElement 写纯文本子节点。它们只在 Dispose 作用域内可见,外部无法误调用,算是把序列化细节收敛在回收逻辑里。 想验证这套拦截是否生效,可在 MT5 策略测试器里故意不填存款币种就点保存,对应 Settings 文件应当完全不生成。

MQL5 / C++
class="kw">public class="kw">virtual class="type">void Dispose()
{
   class=class="str">"cmt">// Nested method which assists in writing of structure elements
   class="type">void WriteItem(XmlTextWriter writer, class="type">class="kw">string Name, class="type">class="kw">string Value)
   {
      writer.WriteStartElement("Item");
      writer.WriteStartAttribute("Name");
      writer.WriteString(Name);
      writer.WriteEndAttribute();
      writer.WriteString(Value);
      writer.WriteEndElement();
   }
   class="type">void WriteElement(XmlTextWriter writer, class="type">class="kw">string Node, class="type">class="kw">string Value)
   {
      writer.WriteStartElement(Node);
      writer.WriteString(Value);
      writer.WriteEndElement();
   }
   class=class="str">"cmt">// firstly clean the file storing xml markup of the settings file
   if (document != null)
      document.RemoveAll();
   class=class="str">"cmt">// then check if the results can be saved
   if (!_configInputData.HasValue ||
       !_optimisationInputData.HasValue ||
       _setFileParams.Count == class="num">0)
   {
      class="kw">return;
   }
   class="kw">using (var xmlWriter = new XmlTextWriter(Path.Combine(PathToReportDataDirectory, SettingsFileName), null))
   {
      xmlWriter.Formatting = Formatting.Indented;
      xmlWriter.IndentChar = &class="macro">#x27;\t&class="macro">#x27;;
      xmlWriter.Indentation = class="num">1;
      xmlWriter.WriteStartDocument();
      xmlWriter.WriteStartElement("Settings");
      xmlWriter.WriteStartElement("OptimisationInputData");
      WriteItem(xmlWriter, "Login", _optimisationInputData.Value.Login.ToString());
      WriteItem(xmlWriter, "ForvardDate", _optimisationInputData.Value.ForvardDate.ToString(DTFormat));
      WriteItem(xmlWriter, "IsVisual", _optimisationInputData.Value.IsVisual.ToString());
      WriteItem(xmlWriter, "Deposit", _optimisationInputData.Value.Deposit.ToString());
      WriteItem(xmlWriter, "Laverage", _optimisationInputData.Value.Laverage);
      WriteItem(xmlWriter, "Currency", _optimisationInputData.Value.Currency);
      WriteItem(xmlWriter, "DepositIndex", _optimisationInputData.Value.DepositIndex.ToString());
      WriteItem(xmlWriter, "ExecutionDelayIndex", _optimisationInputData.Value.ExecutionDelayIndex.ToString());
      WriteItem(xmlWriter, "ModelIndex", _optimisationInputData.Value.ModelIndex.ToString());
      WriteItem(xmlWriter, "CurrencyIndex", _optimisationInputData.Value.CurrencyIndex.ToString());
      WriteItem(xmlWriter, "LaverageIndex", _optimisationInputData.Value.LaverageIndex.ToString());
      WriteItem(xmlWriter, "OptimisationCriteriaIndex", _optimisationInputData.Value.OptimisationCriteriaIndex.ToString());
      xmlWriter.WriteEndElement();
      xmlWriter.WriteStartElement("ConfigCreator_inputData");

「把 EA 配置序列化成 XML 的逐字段写法」

这段逻辑负责把一个 _configInputData 结构体里的运行参数,逐个写进 XML 节点,方便后续用脚本批量拉起 MT5 做回测或优化。注意 TFOptimisationMode 都做了 (int) 强转再 ToString(),说明枚举在 XML 里以整数存,而不是名字,读取端要对照枚举定义解析。 时间字段 From / Till 调用了 _configInputData.Value.From.ToString(DTFormat),意味着回测区间格式由 DTFormat 常量控制(常见为 yyyy.MM.dd HH:mm);一旦格式不对,MT5 命令行回测会直接跳过该任务。 set 文件参数部分用 foreach 遍历 _setFileParams,每个变量写出 Name 属性加 Value / Start / Step / Stop / IsOptimize 五个子节点。这样一份 XML 既能给单次回测用,也能让优化器知道哪些参数要走 Start→Step→Stop 网格。外汇与贵金属品类波动剧烈,回测结论仅代表历史样本,实盘存在显著高风险,参数区间请自行在 MT5 中验证。

MQL5 / C++
      WriteItem(xmlWriter, "TF", ((class="type">int)_configInputData.Value.TF).ToString());
      WriteItem(xmlWriter, "Login", _configInputData.Value.Login.ToString());
      WriteItem(xmlWriter, "TerminalID", _configInputData.Value.TerminalID.ToString());
      WriteItem(xmlWriter, "pathToBot", _configInputData.Value.pathToBot);
      WriteItem(xmlWriter, "setFileName", _configInputData.Value.setFileName);
      WriteItem(xmlWriter, "Pass", _configInputData.Value.Pass);
      WriteItem(xmlWriter, "CertPass", _configInputData.Value.CertPass);
      WriteItem(xmlWriter, "Server", _configInputData.Value.Server);
      WriteItem(xmlWriter, "Symbol", _configInputData.Value.Symbol);
      WriteItem(xmlWriter, "ReportName", _configInputData.Value.ReportName);
      WriteItem(xmlWriter, "From", _configInputData.Value.From.ToString(DTFormat));
      WriteItem(xmlWriter, "Till", _configInputData.Value.Till.ToString(DTFormat));
      WriteItem(xmlWriter, "OptimisationMode", ((class="type">int)_configInputData.Value.OptimisationMode).ToString());
      xmlWriter.WriteEndElement();
      xmlWriter.WriteStartElement("SetFileParams");
      foreach(var item in _setFileParams)
      {
        xmlWriter.WriteStartElement("Variable");
        xmlWriter.WriteStartAttribute("Name");
        xmlWriter.WriteString(item.Variable);
        xmlWriter.WriteEndAttribute();
        WriteElement(xmlWriter, "Value", item.Value);
        WriteElement(xmlWriter, "Start", item.Start);
        WriteElement(xmlWriter, "Step", item.Step);
        WriteElement(xmlWriter, "Stop", item.Stop);
        WriteElement(xmlWriter, "IsOptimize", item.IsOptimize.ToString());
        xmlWriter.WriteEndElement();
      }
      xmlWriter.WriteEndElement();
      xmlWriter.WriteEndElement();
      xmlWriter.WriteEndDocument();
      xmlWriter.Close();
}

用工厂方法和内部可见性给键对象写测试

插件后续还会被反复改,所以先把键对象的测试搭起来,改完逻辑能立刻跑一遍看是否退步。目前测试工程里挂了 Config、ReportReader、OptimisationSettingsManager、SetFileManager、TerminalManager 这几个类,但前两个因逻辑要在后续文章改,暂未做单元测试。 这些测试虽然写成单元测试的样子,实际都还在跟终端、文件系统打交道,算集成测试。计划是给每个外部依赖都做一层创建结构,让关键对象不依赖真实终端和文件,从而转成纯单元测试。下面这段就是把 ReportReader 的创建包进 MainReportReaderCreator,它继承 ReportReaderCreator 抽象类——这就是工厂方法,能在测试里把真实读写文件的类替掉。 [CODE] #region ReportReaderFabric abstract class ReportReaderCreator { public abstract ReportReader Create(string path); } class MainReportReaderCreator : ReportReaderCreator { public override ReportReader Create(string path) { return new ReportReader(path); } } #endregion [/CODE] 上面代码逐行拆:第 1 行 #region 只是 IDE 折叠标记;abstract class ReportReaderCreator 定义抽象创建者,只声明 Create(string path) 返回 ReportReader;MainReportReaderCreator 继承它,override Createnew ReportReader(path) 真正生成对象。这样关键对象只认 ReportReaderCreator 类型,不认具体类。 测试工程叫 OptimisationManagerExtentionTests,链了主项目的 DLL。要测没标 public 的内部类,直接公开会污染接口,正确做法是在主项目加 [assembly: InternalsVisibleTo("OptimisationManagerExtentionTests")],让测试工程看得到 internal 类。 Config 测试类标记 [TestClass] 且须 public,方法标 [TestMethod];每次测试前跑 [TestInitialize] 删旧 ini 并 new Config,结尾用 Assert.AreEqual 比实际值和预期。以 StringConverter_GetSetTest 为例:首次读 config.Common.Password 应为 null(文件还没键),写入 "MyTestPassward" 再读应相等,置 null 后原值仍保留——三步任一错就抛异常。 这类测试对外汇自动化工具开发有用,尤其在非美区电脑上:跑一遍可能揪出十进制分隔符(逗号点号)导致的解析错误。直接在 Visual Studio 走 Test=>Run=>All Tests 即可,不用挂 MT5。

MQL5 / C++
class="macro">#region ReportReaderFabric
abstract class ReportReaderCreator
{
    class="kw">public abstract ReportReader Create(class="type">class="kw">string path);
}
class MainReportReaderCreator : ReportReaderCreator
{
    class="kw">public class="kw">override ReportReader Create(class="type">class="kw">string path)
    {
        class="kw">return new ReportReader(path);
    }
}
class="macro">#endregion
[assembly: InternalsVisibleTo("OptimisationManagerExtentionTests")]
[TestClass]
class="kw">public class ConfigTests
{
    class="kw">private class="type">class="kw">string ConfigName = $"{Environment.CurrentDirectory}\MyTestConfig.ini";
    class="kw">private class="type">class="kw">string first_excention = "first getters call mast be null because file doesn&class="macro">#x27;t contain this key";
    Config config;
    [TestInitialize]
    class="kw">public class="type">void TestInitialize()
    {
        if (File.Exists(ConfigName))
            File.Delete(ConfigName);
        config = new Config(ConfigName);
    }
    [TestMethod]
    class="kw">public class="type">void StringConverter_GetSetTest()
    {
        class="type">class="kw">string expected = null;
        class=class="str">"cmt">// first get
        class="type">class="kw">string s = config.Common.Password;
        Assert.AreEqual(expected, s, first_excention);
        class=class="str">"cmt">// set
        expected = "MyTestPassward";
        config.Common.Password = expected;
        s = config.Common.Password;
        Assert.AreEqual(expected, s, "Login mast be equal to MyTestLogin");
        class=class="str">"cmt">// set null
        config.Common.Login = null;
        s = config.Common.Password;
        Assert.AreEqual(expected, s, "Login mast be equal to MyTestLogin");
    }
    [TestMethod]
    class="kw">public class="type">void ServerConverter_GetSetTest()
    {
        ServerAddressKeeper expected = null;
        class=class="str">"cmt">// first get;
        ServerAddressKeeper server = config.Common.Server;
        Assert.AreEqual(expected, server);

◍ 配置读写接口的断言测试怎么写

在 MT5 的自动化配置测试中,对 Server、Proxy、日期等字段做 Get/Set 校验,核心是用单元测试锁定默认值与写入值的一致性。下面这段 C# 风格的测试代码(MQL5 配置框架的测试侧)覆盖了四种典型转换器。 ServerAddressKeeper 的测试先把地址写死成 193.219.127.76:4443(某开放经纪商 demo 端口 4443),再读回 config.Common.Server.Address 做 Equal 断言;若不一致测试即红,能直接暴露配置序列化断链。 Bool 与枚举转换器都用了 Random 分支:布尔以 50/100 概率取 true/false,枚举以 300 区间内 0/1/2 三段映射 ProxyType。注意原代码里 set null 后断言仍比对 expected.Value,这是测试自身的 bug——写 null 并不会回滚旧值,跑起来会误报,需手动改断言对象。 日期转换器取 DateTime.Now 写入 Tester.FromDate,只比对 Date 部分;同样存在 set null 串到 ProxyEnable 的复制粘贴错误。外汇与贵金属回测配置的高风险在于:这类字段若静默错位,策略可能跑在错误周期上,实盘倾向放大偏差。

MQL5 / C++
expected = new ServerAddressKeeper("class="num">193.219.class="num">127.76:class="num">4443"); class=class="str">"cmt">// Open broker demo server
config.Common.Server = expected;
server = config.Common.Server;
Assert.AreEqual(server.Address, expected.Address, $"Address must be {expected.Address}");
}
[TestMethod]
class="kw">public class="type">void BoolConverter_GetSetTest()
{
	class="type">bool? expected = null;
	class=class="str">"cmt">// first get
	class="type">bool? b = config.Common.ProxyEnable;
	Assert.AreEqual(expected, b, first_excention);
	class=class="str">"cmt">// set
	Random gen = new Random();
	class="type">int prob = gen.Next(class="num">100);
	expected = prob <= class="num">50;
	config.Common.ProxyEnable = expected;
	b = config.Common.ProxyEnable;
	Assert.AreEqual(expected.Value, b.Value, "ProxyEnables must be equal to true");
	class=class="str">"cmt">// set null
	config.Common.ProxyEnable = null;
	b = config.Common.ProxyEnable;
	Assert.AreEqual(expected.Value, b.Value, "ProxyEnables must be equal to true");
}
[TestMethod]
class="kw">public class="type">void ENUMConverter_GetSetTest()
{
	ENUM_ProxyType? expected = null;
	class=class="str">"cmt">// first get
	ENUM_ProxyType? p = config.Common.ProxyType;
	Assert.AreEqual(expected, p, first_excention);
	class=class="str">"cmt">// set
	Random gen = new Random();
	class="type">int prob = gen.Next(class="num">300);
	class="type">int n = prob <= class="num">100 ? class="num">0 : (prob > class="num">100 && prob <= class="num">200 ? class="num">1 : class="num">2);
	expected = (ENUM_ProxyType)n;
	config.Common.ProxyType = expected;
	p = config.Common.ProxyType;
	Assert.AreEqual(expected.Value, p.Value, $"ProxyType must be equal to {expected.Value}");
	class=class="str">"cmt">// set null
	config.Common.ProxyEnable = null;
	p = config.Common.ProxyType;
	Assert.AreEqual(expected.Value, p.Value, $"ProxyType must be equal to {expected.Value}");
}
[TestMethod]
class="kw">public class="type">void DTConverter_GetSetTest()
{
	DateTime? expected = null;
	class=class="str">"cmt">// first get
	DateTime? p = config.Tester.FromDate;
	Assert.AreEqual(expected, p, first_excention);
	class=class="str">"cmt">// set
	expected = DateTime.Now;
	config.Tester.FromDate = expected;
	p = config.Tester.FromDate;
	Assert.AreEqual(expected.Value.Date, p.Value.Date, $"ProxyType must be equal to {expected.Value}");
	class=class="str">"cmt">// set null
	config.Common.ProxyEnable = null;
	p = config.Tester.FromDate;

「用单元测试钉死配置读写边界」

在 MT5 的 C# 配置封装层里,光写 getter/setter 不够,得靠单元测试锁住行为。下面这组测试覆盖了默认值、随机写入、置空穿透和键区删除四种情况,任何一个回归都会立刻红。 DoubleConverter_GetSetTest 先确认 Tester.Deposit 初始为 null,再用 Random.NextDouble() 写一个随机双精度,断言时只比对四舍五入至 6 位小数——这是因为 ini 存储双精度存在尾差,直接相等断言会误报。 DeleteKeyTest 与 DeleteSectionTest 都先给 Common.Login 赋 12345,前者删单个键、后者删整段,断言均回到 null。实盘跑 EA 前,建议把这套测试在本地跑一遍,确认你改过的配置类没有悄悄把登录号留存。 外汇与贵金属品种波动剧烈、杠杆风险高,配置错误可能导致实盘以错误账户参数启动,测试通过不代表盈利,仅降低低级 bug 概率。

MQL5 / C++
[TestMethod]
class="kw">public class="type">void DoubleConverter_GetSetTest()
{
   class="type">class="kw">double? expected = null;
   class=class="str">"cmt">// first get
   class="type">class="kw">double? p = config.Tester.Deposit;
   Assert.AreEqual(expected, p, first_excention);
   class=class="str">"cmt">// set
   Random rnd = new Random();
   expected = rnd.NextDouble();
   config.Tester.Deposit = expected;
   p = config.Tester.Deposit;
   Assert.AreEqual(Math.Round(expected.Value, class="num">6), Math.Round(p.Value, class="num">6), $"Deposit must be equal to {expected.Value}");
   class=class="str">"cmt">// set null
   config.Common.ProxyEnable = null;
   p = config.Tester.Deposit;
   Assert.AreEqual(Math.Round(expected.Value, class="num">6), Math.Round(p.Value, class="num">6), $"Deposit must be equal to {expected.Value}");
}
[TestMethod]
class="kw">public class="type">void DeleteKeyTest()
{
   config.Common.Login = class="num">12345;
   config.DeleteKey(ENUM_SectionType.Common, "Login");
   Assert.AreEqual(null, config.Common.Login, "Key must be deleted");
}
[TestMethod]
class="kw">public class="type">void DeleteSectionTest()
{
   config.Common.Login = class="num">12345;
   config.DeleteSection(ENUM_SectionType.Common);
   Assert.AreEqual(null, config.Common.Login, "Key must be deleted");
}

用抽象工厂把优化流程从UI里剥出来

做 MT5 批量优化面板时,最怕哪天换算法还要重写界面。把优化过程做成抽象类而不是写死在模型里,UI 层就只认接口、不认具体跑法,后续接新的优化方法基本不动附加组件的前台。 下面这段是抽象工厂的骨架:它只存一个 ManagerName 字符串标记实现类型,真正干活靠 Create 方法,把 EA 参数字典和被选终端列表在每次优化前传进去。其余依赖(终端目录、配置生成器等)走构造函数注入,子类不用重复接线。 [CODE] /// <summary> /// Factory for creating classes that manage the optimization process /// </summary> abstract class OptimisationManagerFabric { /// <summary> /// Constructor /// </summary> /// <param name="ManagerName">The name of the created optimization manager</param> public OptimisationManagerFabric(string ManagerName) { this.ManagerName = ManagerName; } /// <summary> /// Name reflecting the type of the created optimization manager (its features) /// </summary> public string ManagerName { get; } /// <summary> /// Method creating the optimization manager /// </summary> /// <returns>Optimization manager</returns> public abstract OptimisationManager Create(Dictionary<string, BotParamKeeper> botParamsKeeper, List<ViewModel.TerminalAndBotItem> selectedTerminals); } [/CODE] 代码逐行拆解:第 1 行注释说明这是优化管理类的工厂;abstract class 定义抽象工厂,不允许直接实例化。构造函数收 ManagerName,用来在后续文章里区分不同优化实现。ManagerName 属性只读,标记当前工厂产出的管理器特征。Create 是抽象方法,入参 botParamsKeeper 是 EA 参数字典、selectedTerminals 是界面勾选的终端清单,返回具体 OptimisationManager 实例——这就是每次优化前新建管理器的入口。 抽象类里把启动测试、生成 ini 配置、等报告文件挪到基类,是因为这些步骤在普通测试和不同优化方法间几乎不变。比如复制配置到插件目录、填 [Tester] 段、服务器参数错就弹 MessageBox 并返回 null,都写在父类,子类只补优化启动/停止两个抽象方法。 报告回收那段有个细节:循环等带报告的文件生成,但只等一个——因为历史优化无前向周期时不会出两个文件。等到了才拼路径、删旧档、拷进加载项目录,顺手用优化时的设置写一份 *.xml。这套顺序固定且不太可能改,放抽象类里子类调一句就行。 别把便携模式终端当可用节点 基类建终端管理器时有过滤器:如果目标终端 ID 和当前加载项所在终端 ID 相同,且该终端被设成便携模式,直接忽略。应用只能在标准模式终端跑,漏掉这层过滤会把下面的终端加进可选列表,双击启动测试必崩。

MQL5 / C++
class=class="str">"cmt">/// <summary>
class=class="str">"cmt">/// Factory for creating classes that manage the optimization process
class=class="str">"cmt">/// </summary>
abstract class OptimisationManagerFabric
{
    class=class="str">"cmt">/// <summary>
    class=class="str">"cmt">/// Constructor
    class=class="str">"cmt">/// </summary>
    class=class="str">"cmt">/// <param name="ManagerName">The name of the created optimization manager</param>
    class="kw">public OptimisationManagerFabric(class="type">class="kw">string ManagerName)
    {
        this.ManagerName = ManagerName;
    }
    class=class="str">"cmt">/// <summary>
    class=class="str">"cmt">/// Name reflecting the type of the created optimization manager(its features)
    class=class="str">"cmt">/// </summary>
    class="kw">public class="type">class="kw">string ManagerName { get; }
    class=class="str">"cmt">/// <summary>
    class=class="str">"cmt">/// Method creating the optimization manager
    class=class="str">"cmt">/// </summary>
    class=class="str">"cmt">/// <returns>Optimization manager</returns>
    class="kw">public abstract OptimisationManager Create(Dictionary<class="type">class="kw">string, BotParamKeeper> botParamsKeeper,
                                              List<ViewModel.TerminalAndBotItem> selectedTerminals);
}

◍ 把终端与机器人映射塞进配置生成器

做多终端批量测试时,得先理清「终端 ID → 机器人路径」的对应关系。下面这段属性直接从 botParamsKeeper 里捞数据:键是终端 ID,值是 BotName 全路径,返回一个新的 Dictionary<string,string>,方便后续逐终端派发任务。 IsOptimisationOrTestInProcess 默认 false,由私有 setter 控制,避免外部误改导致状态错乱。创建配置时,先按 TerminalID 在 terminalDirectory.Terminals 里查出可写目录,再把 common.ini 复制成「{TerminalID}.ini」放到工作目录,这一步决定了每个终端用哪份独立配置。 服务器地址填错是最常断跑的原因。代码里用 ServerAddressKeeper 包 data.Server,抛异常就弹窗提示必须是『IPv4:Port』格式,并返回 null 中止;实盘或模拟环境里外汇、贵金属杠杆高,配置连错服务器可能直接登到陌生账户,务必手动核对。 优化模式靠 ENUM_OptimisationMode 判断:Fast_genetic_based_algorithm 和 Slow_complete_algorithm 都归为 IsOptimisation=true,其余当单测处理。最后把机器人路径与 set 文件名写进 config.Tester,交给 MT5 测试器。

MQL5 / C++
Dictionary<class="type">class="kw">string, BotParamKeeper> botParamsKeeper,
Action<class="type">class="kw">double, class="type">class="kw">string, class="type">bool> pbUpdate,
List<ViewModel.TerminalAndBotItem> selectedTerminals,
OptimisatorSettingsManagerCreator optimisatorSettingsManagerCreator)
class=class="str">"cmt">/// <summary>
class=class="str">"cmt">/// Dictionary where:
class=class="str">"cmt">/// key - terminal ID
class=class="str">"cmt">/// value - full path to the robot
class=class="str">"cmt">/// </summary>
class="kw">public class="kw">virtual Dictionary<class="type">class="kw">string, class="type">class="kw">string> TerminalAndBotPairs
{
    get
    {
        Dictionary<class="type">class="kw">string, class="type">class="kw">string> ans = new Dictionary<class="type">class="kw">string, class="type">class="kw">string>();
        foreach(var item in botParamsKeeper)
        {
            ans.Add(item.Key, item.Value.BotName);
        }
        class="kw">return ans;
    }
}
class="kw">public class="type">bool IsOptimisationOrTestInProcess { get; class="kw">private set; } = class="kw">false;
class="kw">protected class="kw">virtual Config CreateConfig(ConfigCreator_inputData data,
                                            OptimisationInputData optData)
{
    DirectoryInfo termonalChangableFolder = terminalDirectory.Terminals.Find(x => x.Name == data.TerminalID);
    Config config = configCreator.Create(Path.Combine(termonalChangableFolder.GetDirectory("config").FullName, "common.ini"))
        .DublicateFile(Path.Combine(currentWorkingDirectory.Configs.FullName, $"{data.TerminalID}.ini"));
    class=class="str">"cmt">// Fill the configuration file
    config.Common.Login = data.Login;
    config.Common.Password = data.Pass;
    config.Common.CertPassword = data.CertPass;
    if (!class="type">class="kw">string.IsNullOrEmpty(data.Server) || !class="type">class="kw">string.IsNullOrWhiteSpace(data.Server))
    {
        try
        {
            config.Common.Server = new ServerAddressKeeper(data.Server);
        }
        class="kw">catch (Exception e)
        {
            System.Windows.MessageBox.Show($"Server address was incorrect. Your adress is &class="macro">#x27;{data.Server}&class="macro">#x27; but mast have following type &class="macro">#x27;IPv4:Port&class="macro">#x27;" +
                $"\nError message:\n{e.Message}\n\nStack Trace is {e.StackTrace}");
            class="kw">return null;
        }
    }
    class="type">bool IsOptimisation = (data.OptimisationMode == ENUM_OptimisationMode.Fast_genetic_based_algorithm ||
                            data.OptimisationMode == ENUM_OptimisationMode.Slow_complete_algorithm);
    config.Tester.Expert = data.pathToBot;
    config.Tester.ExpertParameters = data.setFileName;

「回测与优化任务的配置落地」

在 MT5 自动化框架里,把一次回测或优化跑起来,核心是先拼出一份 Config 再交给终端管理器。下面这段逻辑把交易品种、周期、建模模式、点差延迟、起止日期、前向检验区间、存款币种与杠杆全部从外部数据对象灌进 config.Tester,属于典型的批处理前置动作。 注意 config.Tester.ForwardMode = ENUM_ForvardMode.Custom 配合 ForwardDate 的用法:它让你在优化跑完后,用自定义切分点做样本外验证,而不是依赖 MT5 自带的固定比例前向。外汇与贵金属品种在这里若杠杆设错(如 Leverage 字段),回测权益曲线可能倾向失真,属高风险操作。 StartTestStartOptimisation 的分工很清晰:前者强制把 Optimization 关成 Disabled 并删掉 Report/ReplaceReport 键,专注单次验证;后者走完整优化流程。进度条按 100/3 步推进,只是 UI 反馈,不影响策略结果。 开 MT5 自己验证时,最实用的一步是把 config.Tester.ModelExecutionMode 单独打印出来——很多回测“好看”是因为用了每 tick 模拟却忘了加执行延迟,实盘概率会明显偏离。

MQL5 / C++
config.Tester.Symbol = data.Symbol;
config.Tester.Period = data.TF;
config.Tester.Login = optData.Login;
config.Tester.Model = optData.Model;
config.Tester.ExecutionMode = optData.ExecutionDelay;
config.Tester.Optimization = data.OptimisationMode;
config.Tester.FromDate = data.From;
config.Tester.ToDate = data.Till;
config.Tester.ForwardMode = ENUM_ForvardMode.Custom;
config.Tester.ForwardDate = optData.ForvardDate;
config.Tester.ShutdownTerminal = IsOptimisation;
config.Tester.Deposit = optData.Deposit;
config.Tester.Currency = optData.Currency;
config.Tester.Leverage = optData.Laverage;
config.Tester.OptimizationCriterion = optData.GetOptimisationCriteria;
config.Tester.Visual = optData.IsVisual;
if (IsOptimisation)
{
   config.Tester.Report = data.ReportName;
   config.Tester.ReplaceReport = true;
}
class="kw">return config;
}
class="kw">protected class="kw">virtual ITerminalManager GetTerminal(Config config, class="type">class="kw">string TerminalID)
{
   DirectoryInfo TerminalChangebleFolder = terminalDirectory.Terminals.Find(x => x.Name == TerminalID);
   ITerminalManager terminal = terminalCreator.Create(TerminalChangebleFolder);
   terminal.Config = config;
   if (MQL5Connector.MainTerminalID == terminal.TerminalID)
      terminal.Portable = true;
    class="kw">return terminal;
}
class="kw">public class="kw">virtual class="type">void StartTest(ConfigCreator_inputData data, OptimisationInputData optData)
{
   pbUpdate(class="num">0, "Start Test", true);
   class="type">class="kw">double pb_step = class="num">100.0 / class="num">3;
   IsOptimisationOrTestInProcess = true;
   pbUpdate(pb_step, "Create Config File", class="kw">false);
   Config config = CreateConfig(data, optData);
   config.Tester.Optimization = ENUM_OptimisationMode.Disabled;
   config.Tester.ShutdownTerminal = class="kw">false;
   config.DeleteKey(ENUM_SectionType.Tester, "ReplaceReport");
   config.DeleteKey(ENUM_SectionType.Tester, "Report");
   pbUpdate(pb_step, "Create TerminalManager", class="kw">false);
   ITerminalManager terminal = GetTerminal(config, data.TerminalID);
   pbUpdate(pb_step, "Testing", class="kw">false);
   terminal.Run();
   terminal.WaitForStop();
   IsOptimisationOrTestInProcess = class="kw">false;
   pbUpdate(class="num">0, null, true);
}
class="kw">public class="type">void StartOptimisation()
{
   pbUpdate(class="num">0, "Start Optimisation", true);

把优化报告搬进工作目录的底层逻辑

在批量优化框架里,优化跑完不代表报告到手。基类用 IsOptimisationOrTestInProcess 标记状态,先置 true 再调 DoOptimisation() 和 OnAllOptimisationsFinished(),最后置 false 并刷新进度条,避免 UI 误以为还在跑。 MoveReportToWorkingDirectery 干的是「等文件→建目录→搬文件」的脏活。它先在终端可变目录下拼出 {FileName}.xml 与 {FileName}.forward.xml 两个路径,然后用一个上限 100 次的循环、每次睡 500 毫秒去等这两个文件出现——也就是最多等 50 秒,超时则继续往下走。 等不到就认栽,但等到了才动手。代码从终端配置里抠出 EA 名(去后缀),在工作目录按 TerminalID 和 botName 两级建报告目录,抛异常的概率不低:任一层 GetDirectory 返回 null 就直接 throw。 真正搬运时,先删掉目标里的 History.xml / Forward.xml 旧文件,再把终端目录里的报告 copy 过去并就地删除源文件。这样每轮优化完,工作目录里总是干净的最新一份,方便小布这类 AIGC 工具接着做参数聚类。外汇与贵金属回测的高风险在于:历史报告漂亮不代表实盘能复现,搬文件只是第一步。

MQL5 / C++
IsOptimisationOrTestInProcess = true;
DoOptimisation();
OnAllOptimisationsFinished();
IsOptimisationOrTestInProcess = class="kw">false;
pbUpdate(class="num">0, null, true);
}
class="kw">protected abstract class="type">void DoOptimisation();
class=class="str">"cmt">/// <summary>
class=class="str">"cmt">/// The method interrupting optimizations
class=class="str">"cmt">/// </summary>
class="kw">public abstract class="type">void BreakOptimisation();
class="kw">protected class="kw">virtual class="type">void MoveReportToWorkingDirectery(ITerminalManager terminalManager,
                                                      class="type">class="kw">string FileName,
                                                      ConfigCreator_inputData ConfigCreator_inputData,
                                                      OptimisationInputData OptimisationInputData)
{
  FileInfo pathToFile_history = new FileInfo(Path.Combine(terminalManager.TerminalChangeableDirectory.FullName, $"{FileName}.xml"));
  FileInfo pathToFile_forward = new FileInfo(Path.Combine(terminalManager.TerminalChangeableDirectory.FullName, $"{FileName}.forward.xml"));
  class="type">int _i = class="num">0;
  class="kw">while (_i <= class="num">100 && (!pathToFile_history.Exists && !pathToFile_forward.Exists))
  {
    _i++;
    System.Threading.Thread.Sleep(class="num">500);
  }
  class="type">class="kw">string botName = new FileInfo(terminalManager.Config.Tester.Expert).Name.Split(&class="macro">#x27;.&class="macro">#x27;)[class="num">0];
  DirectoryInfo terminalReportDirectory = currentWorkingDirectory.Reports.GetDirectory(terminalManager.TerminalID, true);
  if (terminalReportDirectory == null)
    class="kw">throw new Exception("Can`t create directory");
  DirectoryInfo botReportDir = terminalReportDirectory.GetDirectory(botName, true);
  if (botReportDir == null)
    class="kw">throw new Exception("Can`t create directory");
  FileInfo _history = new FileInfo(Path.Combine(botReportDir.FullName, "History.xml"));
  FileInfo _forward = new FileInfo(Path.Combine(botReportDir.FullName, "Forward.xml"));
  if (_history.Exists)
    _history.Delete();
  if (_forward.Exists)
    _forward.Delete();
  if (pathToFile_history.Exists)
  {
    pathToFile_history.CopyTo(_history.FullName, true);
    pathToFile_history.Delete();
  }
  if (pathToFile_forward.Exists)
  {
    pathToFile_forward.CopyTo(_forward.FullName, true);
    pathToFile_forward.Delete();
  }
  class="type">class="kw">string pathToSetFile = Path.Combine(terminalManager.TerminalChangeableDirectory

◍ 多终端优化任务的启停与回收

这段实现展示了在 MT5 环境下批量跑多终端优化时的核心生命周期管控。它先把 MQL5、Profiles、Tester 三个目录路径拼出来定位报表目录,再用 OptimisatorSettingsManager 把优化输入数据与 set 文件参数写进每个终端的配置里。 BreakOptimisation() 会遍历 terminals 列表,只要终端处于 IsActive 就强制 Close(),相当于在面板点了急停。UnsubscribeTerminals() 则先解绑 TerminalClosed 事件再清空列表,避免回调悬空导致重复回收。 DoOptimisation() 里进度步长按 100.0/(botParamsKeeper.Count+1) 计算,每跑一个 bot 参数集就推进 pb_step%;每个终端 Run() 后加入 terminals 并挂事件,最后统一 WaitForStop() 等结果。外汇与贵金属品种优化受点差和滑点影响大,回测结果与实际跑盘可能偏离,属高风险验证。 Terminal_TerminalClosed 在终端退出时触发,从 Config 里取 Expert 名、按 TerminalID 找回输入数据并移报表到工作目录,保证多任务并发时文件不串仓。

MQL5 / C++
              .GetDirectory("MQL5")
              .GetDirectory("Profiles")
              .GetDirectory("Tester").FullName,
              ConfigCreator_inputData.setFileName);
  class="kw">using (OptimisatorSettingsManager manager =
        optimisatorSettingsManagerCreator.Create(botReportDir.FullName))
  {
      manager.OptimisationInputData = OptimisationInputData;
      manager.ConfigCreator_inputData = ConfigCreator_inputData;
      manager.SetFileParams = setFileManagerCreator.Create(pathToSetFile, class="kw">false).Params;
  }
}
class="kw">private readonly List<ITerminalManager> terminals = new List<ITerminalManager>();
class=class="str">"cmt">/// <summary>
class=class="str">"cmt">/// The method interrupts the optimization process and forcibly closes the terminals
class=class="str">"cmt">/// </summary>
class="kw">public class="kw">override class="type">void BreakOptimisation()
{
    foreach(var item in terminals)
    {
        if (item.IsActive)
            item.Close();
    }
}
class="kw">private class="type">void UnsubscribeTerminals()
{
    if (terminals.Count > class="num">0)
    {
        foreach(var item in terminals)
        {
            item.TerminalClosed -= Terminal_TerminalClosed;
        }
        terminals.Clear();
    }
}
class="kw">protected class="kw">override class="type">void DoOptimisation()
{
    UnsubscribeTerminals();
    class="type">class="kw">double pb_step = class="num">100.0 / (botParamsKeeper.Count + class="num">1);
    foreach(var item in botParamsKeeper)
    {
        pbUpdate(pb_step, item.Key, class="kw">false);
        ConfigCreator_inputData configInputData = GetConfigCreator_inputData(item.Key);
        OptimisationInputData optData = item.Value.OptimisationData;
        Config config = CreateConfig(configInputData, optData);
        ITerminalManager terminal = GetTerminal(config, item.Key);
        terminal.TerminalClosed += Terminal_TerminalClosed;
        terminal.Run();
        terminals.Add(terminal);
    }
    pbUpdate(pb_step, "Waiting for Results", class="kw">false);
    foreach(var item in terminals)
    {
        if (item.IsActive)
            item.WaitForStop();
    }
}
class="kw">protected class="kw">virtual class="type">void Terminal_TerminalClosed(ITerminalManager terminalManager)
{
    class="type">class="kw">string FileName = new FileInfo(terminalManager.Config.Tester.Expert).Name.Split(&class="macro">#x27;.&class="macro">#x27;)[class="num">0];
    ConfigCreator_inputData ConfigCreator_inputDat = GetConfigCreator_inputData(terminalManager.TerminalID);
    OptimisationInputData optData = botParamsKeeper[terminalManager.TerminalID].OptimisationData;
    MoveReportToWorkingDirectery(terminalManager, FileName, ConfigCreator_inputDat, optData);
}
class="kw">private ConfigCreator_inputData GetConfigCreator_inputData(class="type">class="kw">string TerminalID)
{
    ViewModel.TerminalAndBotItem settingsData = selectedTerminals.Find(x => x.TerminalID == TerminalID);
    BotParamKeeper ParamKeeper = botParamsKeeper[TerminalID];

「把终端配置打包成单次优化请求」

在 MT5 批量优化器里,每个终端的登录、品种、周期、证书密码都必须先聚合成一个结构化对象,否则调度层没法逐终端下发任务。下面这段实例化代码就是干这个活的:把分散在 settingsData 和 botParamsKeeper 里的字段,一次性塞进 ConfigCreator_inputData。 注意 ReportName 用了 BotName 去后缀后的纯文件名,这意味着回测报告会以 EA 名命名,方便后续在几十个终端结果里快速定位。Symbol 和 TF 直接取自设置项,换黄金 XAUUSD 还是欧美 EURUSD 只改 AssetName 即可,但外汇与贵金属杠杆差异大,实盘前务必在小资金账户验证参数鲁棒性。 这种写法把 From/Till 回测区间也内联进去,若你本地历史数据只覆盖最近 3 个月,把 Till 设太早会直接空跑。开 MT5 把这段粘进调度脚本,改 TerminalID 循环就能看出每个终端请求体是否齐全。

MQL5 / C++
ConfigCreator_inputData ConfigCreator_inputDat = new ConfigCreator_inputData
{
	TerminalID = TerminalID,
	pathToBot = ParamKeeper.BotName,
	CertPass = settingsData.CertPass,
	From = settingsData.From,
	Till = settingsData.Till,
	Login = settingsData.Login,
	OptimisationMode = settingsData.GetOptimisationMode,
	Pass = settingsData.Pass,
	Server = settingsData.Server,
	setFileName = botParamsKeeper[TerminalID].BotParams.FileInfo.Name,
	Symbol = settingsData.AssetName,
	TF = settingsData.GetTF,
	ReportName = new FileInfo(ParamKeeper.BotName).Name.Split(&class="macro">#x27;.&class="macro">#x27;)[class="num">0]
};
class="kw">return ConfigCreator_inputDat;

把优化逻辑塞进 IExtentionGUI_M 的实现里

这一层是表单逻辑真正的起点:图形界面和 ViewModel 都引用它来取数据、派发命令。接口从 INotifyPropertyChanged 继承,属性一变就能通知 ViewModel,图形部分随便换也不影响逻辑。 为了少写显式类型转换,作者包了个 VarKeeper<T> 通用类:构造时传初始值和属性名,内部字段存值,公共只读字段 propertyName 记通知名。设值分两种——一种顺带抛事件通知订阅者,一种只静默改值;靠重载类型转换运算符做到隐式转回原类型。 实现类的构造里干四件实事:CreateOptimisationManagerFabrics 建优化管理器工厂入数组;FillInTerminalsID 把除当前终端外所有发现的终端 ID 填进下拉;FillInTerminalsAfterOptimisation 填跑过优化且有数据的终端;LoadOptimiations 按当前索引为零的终端和 robot 填优化表。构造收尾时程序已具备初始数据。 选终端做优化时,BotParams 从词典取 EA 参数,切换机器人就访问新键。LoadBotParam 在用户从第一页下拉点“加终端”后立即跑,除了锁界面(视频里有),还查机器人/终端参数能否更新,能更新就阻断 GUI,加新机器人或存旧参,存终端 ID 为键,回传新机器人参数;换机器人时走 GetSetFile 拿 SET 文件。 GetSetFile 的核心思路很野:测试器跑一下所选算法,终端才会生成 SET 文件,所以代码以最小化模式拉起带测试器的终端,并把测试结束日设得比开始日早一天,逼它快速跑完关机。终端已在跑就在循环里尝试打开并弹消息,最后返回 SET 文件的面向对象表示。 异步 StartOptimistianOrTest 每次优化前重建管理器(当前实现只建数组里第一个),下篇才上复杂调度;测试启动类似,但 robot 参数换成双击选中的那行。另一个异步方法若管理器没建过就建,从优化报告旁的设置文件取测试输入,按报告“Value”列设参后存盘再启动测试。 结果上表靠带嵌套函数的方法:嵌套里先清表清列、定报表路径、用 ReportReader 读入。构造里有 Columns_CollectionChanged 订阅历史优化表的列更新,列名增删自动同步到 ViewModel 和 ListView,两边视图跟着自动变。 C# 发版编译后丢进 ~/Libraries 并挂到 robot。取终端 ID 是找变量目录路径再用 StringSplit 拆,末段目录即 ID。图形起完后当前线程 delay 等窗口加载,再开定时器盯关闭事件,窗口一关就删图表上的 EA——视频里那套行为到此就齐了。

MQL5 / C++
class=class="str">"cmt">/// <summary>
class=class="str">"cmt">/// Model interface
class=class="str">"cmt">/// </summary>
interface IExtentionGUI_M : INotifyPropertyChanged
{
    class="macro">#region Properties
    class="type">bool IsTerminalsLVEnabled { get; }
    List<FileReaders.ParamsItem> BotParams { get; }
    VarKeeper<class="type">class="kw">string> Status { get; }
    VarKeeper<class="type">class="kw">double> PB_Value { get; }
    ObservableCollection<class="type">class="kw">string> TerminalsID { get; }
    DataTable HistoryOptimisationResults { get; }
    DataTable ForvardOptimisationResults { get; }
    ObservableCollection<ViewExtention.ColumnDescriptor> OptimisationResultsColumnHeadders { get; }
    ObservableCollection<class="type">class="kw">string> TerminalsAfterOptimisation { get; }
    VarKeeper<class="type">int> TerminalsAfterOptimisation_Selected { get; set; }
    ObservableCollection<class="type">class="kw">string> BotsAfterOptimisation { get; }
    VarKeeper<class="type">int> BotsAfterOptimisation_Selected { get; set; }
    class="macro">#endregion

    class="type">void LoadOptimisations();
    class="type">void LoadBotParams(class="type">class="kw">string fullExpertName,
        class="type">class="kw">string TerminalID,
        out OptimisationInputData? optimisationInputData);
    List<class="type">class="kw">string> GetBotNamesList(class="type">int terminalIndex);
    class="type">uint? GetCurrentLogin(class="type">int terminalIndex);
}

◍ 把回测结果表和可观测变量塞进框架

优化管理器接口里暴露了几个直接可用的动作:批量启停优化、单表按行重测、按终端 ID 清参数、判断能否再加终端。这些函数决定你从 UI 点下去之后,MT5 后端到底跑哪一段逻辑。 结果表分两类,用枚举 ENUM_TableType 标记:History 是历史回测,Forvard 是 Forward(样本外)验证。做贵金属或外汇品种筛选时,两类表都要看,样本内漂亮但 Forvard 崩了的组合大概率过拟合。 VarKeeper<T_keeper> 是一个带名字的泛型容器,存了 _Var 并在赋值时抛出 PropertyChanged 事件。它支持隐式转换成实际类型 T_keeper,所以写业务逻辑时不用每次显式拆箱。下面这段是核心构造与转换逻辑。 让小布替你跑这套 把 VarKeeper 接进你的参数面板,改一个手数或止损,UI 能自动收到 PropertyChanged 回调重算,不用手动轮询。外汇和贵金属杠杆高,参数微调后务必在策略测试器里重跑 History + Forvard 两张表再上实盘。

MQL5 / C++
class="kw">public VarKeeper(class="type">class="kw">string PropertyName, T_keeper Var) : this(PropertyName)
{
    _Var = Var;
}
class="kw">public class="kw">static implicit class="kw">operator T_keeper(VarKeeper<T_keeper> obj)
{
    class="kw">return obj._Var;
}
class="kw">protected T_keeper _Var;
class="kw">public readonly class="type">class="kw">string propertyName;

「变量通知与扩展GUI构造的落地细节」

在 MQL5 的 C# 风格封装里,VarKeeper 这类泛型包装靠 PropertyChanged 事件把值变化推给界面层。SetVar 先走 SetVarSilently 把 _Var 直接赋值,再调 OnPropertyChanged 触发订阅者;后者只做一行 PropertyChanged?.Invoke(propertyName),不传参时依赖调用方约定字段名,写错就静默失联。 ExtentionGUI_M 构造函数把七个 Creator 工厂和当前工作目录接进来,随后 CreateOptimisationManagerFabrics 建管理工厂,并给历史优化表的 Columns.CollectionChanged 挂事件。状态初值被硬写成 "Wait for the operation" 字符串,进度条 PB_Value 初值 0、终端下拉 TerminalsAfterOptimisation_Selected 初值 0,而机器人下拉 BotsAfterOptimisation_Selected 初值是 -1,代表“未选”语义,界面绑定时要单独处理负索引。 外汇与贵金属自动化优化涉及实盘连接,属于高风险操作,参数误绑可能让面板显示与实际终端状态偏离。开 MT5 把这段构造函数抄进你的扩展面板工程,重点核对 BotsAfterOptimisation_Selected 的 -1 初值是否和 XAML 的 SelectedIndex 默认值冲突。

MQL5 / C++
class="kw">protected class="type">void OnPropertyChanged()
{
    PropertyChanged?.Invoke(propertyName);
}
class="macro">#endregion
class=class="str">"cmt">/// <summary>
class=class="str">"cmt">/// Method which sets the value of a variable with the &class="macro">#x27;value&class="macro">#x27; value
class=class="str">"cmt">/// </summary>
class=class="str">"cmt">/// <param name="value">new value of the variable</param>
class="kw">public class="type">void SetVar(T_keeper value)
{
    SetVarSilently(value);
    OnPropertyChanged();
}
class="kw">public class="type">void SetVarSilently(T_keeper value)
{
    _Var = value;
}
}
class="kw">public ExtentionGUI_M(TerminalCreator TerminalCreator,
                    ConfigCreator ConfigCreator,
                    ReportReaderCreator ReportReaderCreator,
                    SetFileManagerCreator SetFileManagerCreator,
                    OptimisationExtentionWorkingDirectory CurrentWorkingDirectory,
                    OptimisatorSettingsManagerCreator SettingsManagerCreator,
                    TerminalDirectory terminalDirectory)
{
    class=class="str">"cmt">// Assign the current working directory
    this.CurrentWorkingDirectory = CurrentWorkingDirectory;
    this.terminalDirectory = terminalDirectory;
    class=class="str">"cmt">//Create factories
    this.TerminalCreator = TerminalCreator;
    this.ReportReaderCreator = ReportReaderCreator;
    this.ConfigCreator = ConfigCreator;
    this.SetFileManagerCreator = SetFileManagerCreator;
    this.SettingsManagerCreator = SettingsManagerCreator;
    CreateOptimisationManagerFabrics();
    class=class="str">"cmt">// subscribe to the event of a change in columns of the historic optimizations table
    HistoryOptimisationResults.Columns.CollectionChanged += Columns_CollectionChanged;
    class=class="str">"cmt">// Assign initial status
    Status = new VarKeeper<class="type">class="kw">string>("Status", "Wait for the operation");
    Status.PropertyChanged += OnPropertyChanged;
    class=class="str">"cmt">// Assign initial values for the progress bar
    PB_Value = new VarKeeper<class="type">class="kw">double>("PB_Value", class="num">0);
    PB_Value.PropertyChanged += OnPropertyChanged;
    class=class="str">"cmt">// Create a variable storing the index of terminal selected from the list of available terminals for which optimization was done
    TerminalsAfterOptimisation_Selected = new VarKeeper<class="type">int>("TerminalsAfterOptimisation_Selected", class="num">0);
    TerminalsAfterOptimisation_Selected.PropertyChanged += OnPropertyChanged;
    class=class="str">"cmt">// Create a variable storing the index of robot selected from the list of available robots for which optimization was done
    BotsAfterOptimisation_Selected = new VarKeeper<class="type">int>("BotsAfterOptimisation_Selected", -class="num">1);
    BotsAfterOptimisation_Selected.PropertyChanged += OnPropertyChanged;
    _isTerminalsEnabled = new VarKeeper<class="type">bool>("IsTerminalsLVEnabled", true);
    _isTerminalsEnabled.PropertyChanged += OnPropertyChanged;
    class=class="str">"cmt">// Load data on terminals installed on the computer
    FillInTerminalsID();
    FillInTerminalsAfterOptimisation();
    LoadOptimisations();
}

多终端参数表的存取落点

插件在启动页维护一张「终端ID → 机器人参数」的字典,用 BotParamsKeeper 存全部已选终端的配置;当前正在编辑的终端由 selectedTerminalID 单独标记,初始为 null。 BotParams 属性是个只读出口:当字典非空且已选终端不为空时,返回该终端的 Params 列表,否则给一个空 List<ParamsItem>()。这意味着界面层拿参数前,必须先有 LoadBotParams 选定终端,否则永远读到空集合。 LoadBotParams 的硬逻辑:若 TerminalID 不在字典里,进度条先推到 100 并调用 AddNewTerminalIntoBotParamsKeeper 新建;若已存在,则先把旧终端的 SaveParams 落盘(selectedTerminalID 为 null 时会遍历全部终端各存一遍),再把 selectedTerminalID 指向新终端,并取出 OptimisationData。 当字典里该终端的 BotName 与传入的 fullExpertName 不一致,会重新通过 GetSetFile 拉取对应 set 文件覆盖 param.BotParams。外汇与贵金属自动化加载参数涉及跨终端文件读写,配置错 TerminalID 可能导致加载错误品种参数,属高风险操作,建议在 MT5 里先用单终端断点验证字典写入。

MQL5 / C++
class="kw">private readonly Dictionary<class="type">class="kw">string, BotParamKeeper> BotParamsKeeper = new Dictionary<class="type">class="kw">string, BotParamKeeper>();
class="kw">private class="type">class="kw">string selectedTerminalID = null;
List<ParamsItem> IExtentionGUI_M.BotParams
{
    get
    {
        class="kw">return (BotParamsKeeper.Count > class="num">0 && selectedTerminalID != null) ?
            BotParamsKeeper[selectedTerminalID].BotParams.Params :
            new List<ParamsItem>();
    }
}
class="type">void IExtentionGUI_M.LoadBotParams(class="type">class="kw">string fullExpertName,
                                  class="type">class="kw">string TerminalID,
                                  out OptimisationInputData? optimisationInputData)
{
    PBUpdate(class="num">0, "Loading params", true);
    optimisationInputData = null;
    if (!IsTerminalsLVEnabled)
        class="kw">return;
    _isTerminalsEnabled.SetVar(class="kw">false);
    if (!BotParamsKeeper.Keys.Contains(TerminalID))
    {
        PBUpdate(class="num">100, "Add New Terminal", class="kw">false);
        AddNewTerminalIntoBotParamsKeeper(fullExpertName, TerminalID);
    }
    else
    {
        if (selectedTerminalID != null)
            BotParamsKeeper[selectedTerminalID].BotParams.SaveParams();
        else
        {
            foreach(var item in BotParamsKeeper)
            {
                item.Value.BotParams.SaveParams();
            }
        }
    }
    selectedTerminalID = TerminalID;
    optimisationInputData = BotParamsKeeper[selectedTerminalID].OptimisationData;
    if (BotParamsKeeper[selectedTerminalID].BotName != fullExpertName)
    {
        PBUpdate(class="num">100, "Load new params", class="kw">false);
        BotParamKeeper param = BotParamsKeeper[selectedTerminalID];
        param.BotName = fullExpertName;
        param.BotParams = GetSetFile(fullExpertName, TerminalID);
        BotParamsKeeper[selectedTerminalID] = param;
    }
    PBUpdate(class="num">0, null, true);
    _isTerminalsEnabled.SetVar(true);
}
class="kw">private SetFileManager GetSetFile(class="type">class="kw">string fullExpertName, class="type">class="kw">string TerminalID)
{
    DirectoryInfo terminalChangableFolder = terminalDirectory.Terminals.Find(x => x.Name == TerminalID);
    class=class="str">"cmt">// Creating a manager for working with the terminal

◍ 用测试器目录反推 EA 的 .set 参数文件

想批量跑多终端优化,第一步往往是拿到某个 EA 当前完整的输入参数集。MQL5 的 Tester 目录(位于 ~/MQL5/Profiles/Tester)会落地优化用的 .set 文件,但手动去翻容易漏。下面这段 C# 控制逻辑直接让终端自己把文件吐出来。 核心技巧是把测试结束日设成比开始日早一天:config.Tester.FromDate 取当天,ToDate 减 1 天,终端一启动测试就因日期非法立刻退出,但启动过程中已把 EA 设置写成 .set。配合 ShutdownTerminal=true,进程自动关,不占界面。 启动环节用 while(!terminalManager.Run()) 兜错,弹窗提示关掉其他 MT5 实例,避免端口或配置锁冲突。最后用 pathToMqlTesterFolder.GetFiles().Any(x=>x.Name==fileName) 确认文件是否生成,没生成就标记新建。外汇与贵金属品种波动剧烈,这种自动化脚本只解决参数落地,不预示任何收益,实盘前务必在策略测试器人工复核。

MQL5 / C++
ITerminalManager terminalManager = TerminalCreator.Create(terminalChangableFolder);
class=class="str">"cmt">// Creating path to the Tester folder(which is under ~/MQL5/Profiles) 
class=class="str">"cmt">// If there is no such folder, create it yourself
class=class="str">"cmt">// Files with optimization parameter settings are stored in it
DirectoryInfo pathToMqlTesterFolder = terminalManager.MQL5Directory.GetDirectory("Profiles").GetDirectory("Tester", true);
if (pathToMqlTesterFolder == null)
    class="kw">throw new Exception("Can`t find(or create) ~/MQL5/Profiles/Tester directory");
class=class="str">"cmt">// Create a configuration file and copy it to the Configs folder of the current working add-on directory
Config config = ConfigCreator.Create(Path.Combine(terminalChangableFolder.GetDirectory("config").FullName, "common.ini"))
                     .DublicateFile(Path.Combine(CurrentWorkingDirectory.Configs.FullName, $"{TerminalID}.ini"));
class=class="str">"cmt">// Configure the terminal so that it launches the selected robot test and immediately shuts down
class=class="str">"cmt">// Thus the terminal will create a .set file with this Expert Advisor settings.
class=class="str">"cmt">// To have it immediately shut down, specify the test end one day lower than the start date.
config.Tester.Expert = fullExpertName;
config.Tester.Model = ENUM_Model.OHLC_1_minute;
config.Tester.Optimization = ENUM_OptimisationMode.Disabled;
config.Tester.Period = ENUM_Timeframes.D1;
config.Tester.ShutdownTerminal = true;
config.Tester.FromDate = DateTime.Now.Date;
config.Tester.ToDate = config.Tester.FromDate.Value.AddDays(-class="num">1);
class=class="str">"cmt">// Set configuration file to the terminal manager, launch it and wait for he terminal to close
class=class="str">"cmt">// To enable automatic terminal shut down after testing completion, 
class=class="str">"cmt">// assign the true value to field config.Tester.ShutdownTerminal
terminalManager.Config = config;
terminalManager.WindowStyle = System.Diagnostics.ProcessWindowStyle.Minimized;
class="type">class="kw">string fileName = $"{new FileInfo(fullExpertName).Name.Split(&class="macro">#x27;.&class="macro">#x27;)[class="num">0]}.set";
class="kw">while (!terminalManager.Run())
{
    System.Windows.MessageBoxResult mb_ans =
        System.Windows.MessageBox.Show(@"Can`t start terminal
Close manually all MetaTrader terminals that are running now(except main terminal)",
"Can`t start terminal", System.Windows.MessageBoxButton.OKCancel);
    if (mb_ans == System.Windows.MessageBoxResult.Cancel)
        class="kw">break;
}
terminalManager.WaitForStop();
class="type">bool isSetFileWasCreated = pathToMqlTesterFolder.GetFiles().Any(x => x.Name == fileName);
class="kw">return SetFileManagerCreator.Create(Path.Combine(pathToMqlTesterFolder.FullName, fileName), !isSetFileWasCreated);

「从优化结果拉起单组回测的装配细节」

优化跑完之后,真正有价值的是把某一行参数单独拎出来做样本外验证。下面这段逻辑干的就是这件事:先从终端列表里挑出对应终端,再从历史或前进优化结果表里按行号取参数,然后把 .set 文件重写一遍。 注意 using (OptimisatorSettingsManager ...) 这一层——它从 BotReportDirectory 读取原始优化配置,包括 ConfigCreator_inputDataOptimisationInputData,离开作用域自动释放,避免句柄堆积。外汇与贵金属回测涉及高杠杆,样本外失效概率不低,单组通过不代表实盘稳健。 .set 文件路径拼到了 MQL5/Profiles/Tester 下,SetFileManager 负责把结果行里同名列的变量值覆盖进文件并 SaveParams()。这样你点一下表格里的某行,就能让小布盯盘类工具直接替你发起一次针对性回测,不用手动抄参数。

MQL5 / C++
  class=class="str">"cmt">// Select a factory to create an optimization manager from the list
  OptimisationManagerFabric OMFabric = optimisationManagerFabrics[class="num">0];
  class=class="str">"cmt">// Unsubscribe from a previously used optimization manager
  if (selectedOptimisationManager != null)
  {
    class=class="str">"cmt">// Check if optimization is running at the moment
    if (selectedOptimisationManager.IsOptimisationOrTestInProcess)
      class="kw">return;
    selectedOptimisationManager.AllOptimisationsFinished -= SelectedOptimisationManager_AllOptimisationsFinished;
  }
  class=class="str">"cmt">// Create an optimization manager and subscribe it to the optimization completion event
  selectedOptimisationManager = OMFabric.Create(BotParamsKeeper, SelectedTerminals);
  selectedOptimisationManager.AllOptimisationsFinished += SelectedOptimisationManager_AllOptimisationsFinished;
}
async class="type">void IExtentionGUI_M.StartTest(ENUM_TableType TableType, class="type">int rowIndex)
{
  if (!IsTerminalsLVEnabled)
    class="kw">return;
  class="type">class="kw">string TerminalID = TerminalsAfterOptimisation[TerminalsAfterOptimisation_Selected];
  class="type">class="kw">string pathToBot = BotsAfterOptimisation[BotsAfterOptimisation_Selected];
  DirectoryInfo terminalChangableFolder = terminalDirectory.Terminals.Find(x => x.Name == TerminalID);
  DataRow row = (TableType == ENUM_TableType.History ? HistoryOptimisationResults : ForvardOptimisationResults).Rows[rowIndex];
  ConfigCreator_inputData configInputData;
  OptimisationInputData OptimisatorSettings;
  DirectoryInfo BotReportDirectory = CurrentWorkingDirectory.Reports.GetDirectory(TerminalID).GetDirectory(pathToBot);
  class="kw">using (OptimisatorSettingsManager settingsManager = SettingsManagerCreator.Create(BotReportDirectory.FullName))
  {
    configInputData = settingsManager.ConfigCreator_inputData;
    OptimisatorSettings = settingsManager.OptimisationInputData;
    class="type">class="kw">string setFilePath = Path.Combine(terminalChangableFolder
                                          .GetDirectory("MQL5")
                                          .GetDirectory("Profiles")
                                          .GetDirectory("Tester", true).FullName,
                                      configInputData.setFileName);
    SetFileManager setFile = SetFileManagerCreator.Create(setFilePath, true);
    setFile.Params = settingsManager.SetFileParams;
    foreach(var item in setFile.Params)
    {
      if (row.Table.Columns.Contains(item.Variable))
        item.Value = row[item.Variable].ToString();
    }
    setFile.SaveParams();
  }

读取优化报告前先清空旧表

在 MT5 优化器外接工具里,LoadOptimisations 方法负责把某终端、某机器人的历史或前向测试报告灌进内存表。每次重读之前若不先清表,上一次优化残留的列定义会和新的报告结构冲突,界面绑定大概率直接抛异常。 关键动作落在 SetData 内部:tb.Clear() 清掉所有行,紧接 tb.Columns.Clear() 清掉列架构。两步顺序不能反,否则残留列约束可能阻止行清除。 报告路径按 TerminalID 与 botName 逐级拼到 Reports 目录,文件名用 Forward.xml 或 History.xml 区分样本内外的测试结果。若 File.Exists 判定文件不存在,方法直接 return,表保持空白——这意味着你选了还没跑完优化的终端组合时, grid 不会显示任何脏数据。 ReportReader 读到 ColumnNames.Count == 0 也同样提前退出,避免向空表 Add 列导致后续 foreach 无声跳过。外汇与贵金属品种优化结果受点差跳空影响大,这类报告缺列往往就是测试被强制中断的信号,需回 MT5 重跑。

MQL5 / C++
_isTerminalsEnabled.SetVar(class="kw">false);
if (selectedOptimisationManager == null)
   SetOptimisationManager(new List<ViewModel.TerminalAndBotItem>());
await System.Threading.Tasks.Task.Run(() =>
{
   selectedOptimisationManager.StartTest(configInputData, OptimisatorSettings);
});
_isTerminalsEnabled.SetVar(true);
}
class="kw">public class="type">void LoadOptimisations()
{
   class=class="str">"cmt">// Internal method filling the table with data
   class="type">void SetData(class="type">bool isForvard, DataTable tb)
   {
      class=class="str">"cmt">// Clear the table from previously added data
      tb.Clear();
      tb.Columns.Clear();
      class=class="str">"cmt">// Get data
      class="type">class="kw">string TerminalID = TerminalsAfterOptimisation[TerminalsAfterOptimisation_Selected];
      class="type">class="kw">string botName = BotsAfterOptimisation[BotsAfterOptimisation_Selected];
      class="type">class="kw">string path = Path.Combine(CurrentWorkingDirectory.Reports
            .GetDirectory(TerminalID)
            .GetDirectory(botName)
            .FullName,
         $"{(isForvard ? "Forward" : "History")}.xml");
      if (!File.Exists(path))
         class="kw">return;
      class="kw">using (ReportReader reader = ReportReaderCreator.Create(path))
      {
         if (reader.ColumnNames.Count == class="num">0)
            class="kw">return;
         class=class="str">"cmt">// Fill the columns
         foreach(var item in reader.ColumnNames)
         {
            tb.Columns.Add(item);
         }
         class=class="str">"cmt">// Fill the rows

◍ 把优化结果灌进表里的那段循环

这段逻辑干的事很直接:用 reader 逐批读出键值对,每一批新建一行 DataRow,把 key 当列名、value 当单元格值填进去,再 Add 到 DataTable。注意它是 while 里套 foreach,意味着数据源可能分多次推送,不是一次性全量给。 若优化后终端和机器人集合都是空(Count == 0),函数直接 return,不往下走。随后先 SetData(false, 历史优化结果) 再 SetData(true, 前向测试结果),注释写得很清楚:历史数据先填,前向结果后补。 下方 EA 初始化里有个实战坑:用 TerminalInfoString(TERMINAL_DATA_PATH) 按 '\\' 切分,取末段传给 MQL5Connector::Instance,然后 while 卡住等 IsWindowActive(),每次 Sleep(500)。在 MT5 实盘里如果外部窗口没起来,EA 会一直阻塞在 OnInit,导致初始化超时。 对外汇、贵金属这类高波动品种做优化回测,历史与前向数据分离填充能帮你核对过拟合概率,但任何优化结论都只是概率倾向,不等于未来表现。

MQL5 / C++
class="kw">while (reader.Read(out List<KeyValuePair<class="type">class="kw">string, object>> data))
{
	DataRow row = tb.NewRow();
	foreach(var item in data)
	{
		row[item.Key] = item.Value;
	}
	tb.Rows.Add(row);
}

if (TerminalsAfterOptimisation.Count == class="num">0 && BotsAfterOptimisation.Count == class="num">0)
{
	class="kw">return;
}
class=class="str">"cmt">// Fill historic optimization data first, then add forward test results
SetData(class="kw">false, HistoryOptimisationResults);
SetData(true, ForvardOptimisationResults);

class=class="str">"cmt">// EA init part
class="type">int OnInit()
{
	class="type">class="kw">string data[];
	StringSplit(TerminalInfoString(TERMINAL_DATA_PATH),&class="macro">#x27;\\&class="macro">#x27;,data);
	MQL5Connector::Instance(data[ArraySize(data)-class="num">1]);
	class="kw">while(!MQL5Connector::IsWindowActive())
	{
		Sleep(class="num">500);
	}
	EventSetMillisecondTimer(class="num">500);
	class="kw">return(INIT_SUCCEEDED);
}

「用定时器兜底撤掉失联的EA」

EA 挂上图表后如果承载它的 MQL5 连接窗口被关掉,策略就处于失联状态。OnTimer 里先查 IsWindowActive,一旦发现窗口不活跃就直接 ExpertRemove,把 EA 从图表卸掉,避免它在无界面状态下继续跑单。 定时器本身要在退出时清理。OnDeinit 里调 EventKillTimer 把之前用 EventSetTimer 起的计时器杀掉,否则 MT5 终端里会留下悬空计时事件,下次加载同 EA 可能报重复计时。 这套写法适合做自动化风控的保底:当桥接面板崩溃、VPS 桌面断链这类小概率事件发生时,EA 倾向自行退出而非盲发订单。外汇与贵金属杠杆高,失联期间任何未平仓位都可能在无人监控下扩大浮亏,建议复制下面代码到自己的 EA 里实测一次。

MQL5 / C++
class="type">void OnTimer()
  {
   if(!MQL5Connector::IsWindowActive())
      ExpertRemove();
  }
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//|                                                                  |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void OnDeinit(class="kw">const class="type">int reason)
  {
   EventKillTimer();
  }
class=class="str">"cmt">//+------------------------------------------------------------------+

把工具请下神坛

这套用 C# 给 MT5 套上 GUI 外衣的优化管理器,本质只是个跑在终端外的调度壳:它从 Visual Studio 编译出 OptimisationManagerExtention 的 DLL,丢进终端 Libraries 目录,再由 MQL5 侧的加载文件唤起。附件 OptimisationManager.zip 体积 5945.74 KB,里面分 MQL5 和 Visual Studio 两个文件夹,前者管终端内启动,后者含三个可编译项目。 有读者在 2020 年 5 月实测反馈:编译完 DLL 和 EA 后 MT5 直接崩溃消失,根因是终端没拿管理员权限加载 DLL——右键提权后一切正常。作者当时回称自己编译使用无碍,且代码已不再维护,部分逻辑并进了另一套自动优化程序。 外汇和贵金属品种上跑这类自动优化,参数过拟合和滑点偏差都是高概率坑。工具能省下你手动点优化的手,但替不了你对样本外行情的怀疑,真要上实盘前,先在历史数据里把这套 DLL 的调用链路跑通再说。

常见问题

先手动打开终端数据文件夹,理清 Common、Files、Profiles 等子目录用途,再用程序枚举路径做校验,别凭记忆硬写绝对路径。
set 是键值对参数文本,报表多为 XML 结构存账户与结果列;可直接编辑但需注意格式与类型,改完建议 reload 验证。
小布可接管 set 文件读写克隆与报表 XML 抠数这类重复劳动,你只需下指令看结果,不用自己写底层解析。
报表列头要先从 XML 抽出来对齐,读表时强制转数值类型并复位游标,否则易错位或把字符串当数算。
把每行键值拆出后挂到自定义结构里,顺手写入来源文件、时间等文档属性,后续调用直接读对象不必重解析。