基于 .Net 框架和 C# 开发图形界面(第二部分):另外的图形元素·综合运用
(3/3)·当基础按钮文本框已玩熟,剩下那些能决定面板专业度的控件你还没接进 MQL5
「日期选择控件的时间格式暗坑」
在 MT5 自定义面板里做日期时间选择,控件交互本身比数字输入框省心:日期格式相对通用,开发时定好就不用跟着交易品种环境变,它只抛出一个 DateTimePickerChange 事件,lparam 里直接带选中时刻。 但真要落地,麻烦在 MQL 和 C# 的时间基底不一样。MQL 用简化 POSIX 时间,秒级分辨率,最早只能到 1970.01.01;C# 的 DateTime 是 100 纳秒刻度。两边类型不互通,GuiController 里就塞了一个 MtConverter 静态类专门做桥接,靠 long 型当中间载体来回倒。 接收事件时如果不显式把 lparam 转成 (datetime),拿到的时间值是错的。下面这段从事件队列抓 DateTimePickerChange 并打印,日志实测出现过「2019.05.16 14:21」「2021.05.16 10:21」这样的跳变,说明用户改一次就触发一次。 自己反向设值也一样,比如用终端当前时间填回控件,也得走同一套转换,别直接塞 long 进去。
<span class="keyword">for</span>(<span class="keyword">class="kw">static</span> <span class="keyword">class="type">int</span> i = <span class="number">class="num">0</span>; i < GuiController::EventsTotal(); i++) { <span class="keyword">class="type">int</span> id; <span class="keyword">class="type">class="kw">string</span> el_name; <span class="keyword">class="type">long</span> lparam; <span class="keyword">class="type">class="kw">double</span> dparam; <span class="keyword">class="type">class="kw">string</span> sparam; GuiController::GetEvent(i, el_name, id, lparam, dparam, sparam); <span class="keyword">if</span>(id == DateTimePickerChange) <span class="functions">printf</span>("用户设置了<span class="keyword">新</span> <span class="keyword">class="type">class="kw">datetime</span> 值: " + <span class="functions">TimeToString</span>((<span class="keyword">class="type">class="kw">datetime</span>)lparam)); } User <span class="keyword">set</span> a <span class="keyword">new</span> class="type">class="kw">datetime <span class="keyword">value</span>: <span class="number">class="num">2019.05</span>.<span class="number">class="num">16</span> <span class="number">class="num">14</span>:<span class="number">class="num">21</span> User <span class="keyword">set</span> a <span class="keyword">new</span> class="type">class="kw">datetime <span class="keyword">value</span>: <span class="number">class="num">2019.05</span>.<span class="number">class="num">16</span> <span class="number">class="num">10</span>:<span class="number">class="num">21</span> User <span class="keyword">set</span> a <span class="keyword">new</span> class="type">class="kw">datetime <span class="keyword">value</span>: <span class="number">class="num">2021.05</span>.<span class="number">class="num">16</span> <span class="number">class="num">10</span>:<span class="number">class="num">21</span> <span class="comment">class=class="str">"cmt">/// <summary></span> <span class="comment">class=class="str">"cmt">/// MetaTrader - C# 系统转换器 </span> <span class="comment">class=class="str">"cmt">/// </summary></span> <span class="keyword">class="kw">public</span> <span class="keyword">class="kw">static</span> <span class="keyword">class</span> MtConverter { <span class="comment">class=class="str">"cmt">/// <summary></span> <span class="comment">class=class="str">"cmt">/// 把 C# 日期时间格式改为 MQL(POSIX) 日期时间格式</span> <span class="comment">class=class="str">"cmt">/// </summary></span> <span class="comment">class=class="str">"cmt">/// <param name="date_time"></param></span> <span class="comment">class=class="str">"cmt">/// <returns></returns></span> <span class="keyword">class="kw">public</span> <span class="keyword">class="kw">static</span> <span class="keyword">class="type">long</span> ToMqlDateTime(DateTime date_time) { DateTime tiks_1970 = <span class="keyword">new</span> DateTime(<span class="number">class="num">1970</span>, <span class="number">class="num">01</span>, <span class="number">class="num">01</span>); <span class="keyword">if</span> (date_time < tiks_1970) <span class="keyword">class="kw">return</span> <span class="number">class="num">0</span>; TimeSpan time_delta = date_time - tiks_1970; <span class="keyword">class="kw">return</span> (<span class="keyword">class="type">long</span>)Math.Floor(time_delta.TotalSeconds); } <span class="comment">class=class="str">"cmt">/// <summary></span> <span class="comment">class=class="str">"cmt">/// 把 MQL(Posix) 时间格式转换为 C# 时间格式</span> <span class="comment">class=class="str">"cmt">/// </summary></span> <span class="comment">class=class="str">"cmt">/// <param name="mql_time">MQL 日期时间单位</param></span> <span class="comment">class=class="str">"cmt">/// <returns></returns></span> <span class="keyword">class="kw">public</span> <span class="keyword">class="kw">static</span> DateTime ToSharpDateTime(<span class="keyword">class="type">long</span> mql_time) { DateTime tiks_1970 = <span class="keyword">new</span> DateTime(<span class="number">class="num">1970</span>, <span class="number">class="num">01</span>, <span class="number">class="num">01</span>); <span class="keyword">if</span> (mql_time <= <span class="number">class="num">0</span> || mql_time > <span class="keyword">class="type">int</span>.MaxValue) <span class="keyword">class="kw">return</span> tiks_1970; TimeSpan time_delta = <span class="keyword">new</span> TimeSpan(<span class="number">class="num">0</span>, <span class="number">class="num">0</span>, (<span class="keyword">class="type">int</span>)mql_time); DateTime sharp_time = tiks_1970 + time_delta; <span class="keyword">class="kw">return</span> sharp_time; } } <span class="comment">class=class="str">"cmt">//-- 显式将 class="type">long 值转换为datetime。它是完全安全的</span> <span class="functions">printf</span>(<span class="class="type">class="kw">string">"User set new class="type">class="kw">datetime value: "</span> + <span class="functions">TimeToString</span>(<span style="background-class="type">color:rgb(class="num">255, class="num">242, class="num">153);">(<span class="keyword">class="type">class="kw">datetime</span>)lparam)</span>)
把当前时间塞进日期选择器事件
在 MQL5 的 GUI 控制逻辑里,日期选择器(DateTimePicker)的状态变更需要主动推事件给控制器。下面这行就是把 MT5 当前服务器时间转成 long 后,作为主参数发过去。 调用 GuiController::SendEvent 时,第一个参数是控件名 "DateTimePicker",第二个是事件 ID DateTimePickerChange,表明这是一次时间变更。第三参数用 (long)TimeCurrent() 把秒级时间戳强转成长整型,第四、五参数分别是 double 与字符串占位,这里填 0.0 和空串即可。 你在 EA 里若想默认让日期选择器显示“此刻”,直接在初始化或按钮回调里发这条事件就行;外汇与贵金属行情受时段影响大,用服务器时间而非本地时间能避免跨时区误判,这类 GUI 同步失误可能引发错单,属高风险操作环节。
GuiController::SendEvent("DateTimePicker", DateTimePickerChange, ((class="type">long)TimeCurrent()), class="num">0.0, "");
◍ 用事件指令收放界面元素
在 MT5 的 WinForms 面板里,ElementHide 与 ElementEnable 是两个能直接遥控任意控件的通用事件。前者决定元素显隐,后者决定元素是否可交互,两者都不依赖具体控件类型,适合做动态界面。 隐藏一个名为 HideGroup 的组,只需向 GuiController 发一条 ElementHide 事件并把标志置 true;同一调用把标志改 false,组就重新出现。注意 WindowsForm 里单个控件常带内部子元素,即元素嵌套,对父组发事件会连带其子 label、文本框一起动作。 ElementEnable 则不让元素消失,只是置灰不可点。把标志填 false 即禁用,换 true 恢复。做复杂 EA 面板时,这比硬隐藏更直观,用户能看见但点不动,减少误操作。 下面四行是可直接抄进 EA 的发送示例:前两行控制 HideGroup 显隐,后两行把 element 禁用再启用。
GuiController::SendEvent("HideGroup", ElementHide, true, class="num">0.0, ""); GuiController::SendEvent("HideGroup", ElementHide, false, class="num">0.0, ""); GuiController::SendEvent("element", ElementEnable, false, class="num">0.0, ""); GuiController::SendEvent("element", ElementEnable, true, class="num">0.0, "");
「用 AddItem 给组合框灌入实时品种」
图形界面里有些容器在编译期内容是空的,比如让用户挑交易品种的组合框。不同券商能看到的品种不一样,硬写死列表不现实,只能等 EA 跑起来再动态填。 GuiController 目前只给组合框开放了 AddItem 事件,别的控件暂不支持挂子元素。够用就行,后续版本才可能扩。 下面这段初始化函数把 MarketWatch 里所有可见品种(SymbolsTotal(true) 的返回值)逐个塞进名叫 SymbolComboBox 的下拉框。想验证就开 MT5 新建 EA,把代码贴进 OnInit,表单里放个对应 ID 的组合框即可看到效果。外汇和贵金属杠杆高,实盘前先在策略测试器跑通界面逻辑。
class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| EA 交易初始化函数 | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">int OnInit() { EventSetMillisecondTimer(class="num">100); class=class="str">"cmt">// 设 class="num">100 毫秒定时器,驱动界面事件轮询 GuiController::ShowForm(assembly, "SendOrderForm"); class=class="str">"cmt">// 加载并展示名为 SendOrderForm 的表单 for(class="type">int i = class="num">0; i < SymbolsTotal(true); i++) class=class="str">"cmt">// 遍历 MarketWatch 全部可见品种,i 从 class="num">0 到总数-class="num">1 { GuiController::SendEvent("SymbolComboBox", AddItem, class="num">0, class="num">0, SymbolName(i, true)); class=class="str">"cmt">// 向 SymbolComboBox 发 AddItem 事件,把第 i 个品种全名加为菜单项 } class="kw">return(INIT_SUCCEEDED); class=class="str">"cmt">// 初始化成功返回 }
GUI 控件的异常回传机制
写 MT5 面板程序不可能零错误,尤其当表单元素和 EA 双向通信时,很多故障发生在运行期而非编译期。GuiController 内置的 Exception 事件就是给这类场景用的:它在自身捕获到系统异常,或 EA 发来的事件对不上元素类型、带了非法值时,主动把错误消息抛回 EA。 以 NumericUpDown 为例。该控件默认取值范围是 0–100,若在黄金图表上把当前价(约 1292 美元/金衡盎司)写进去,而代码里漏了设置最大值,表单会直接显示 0。外汇与贵金属波动大、价格绝对值常远超 100,这种越界是高概率踩坑点,属正常开发风险。 异常不是崩溃,而是可读取的反馈。用 GuiController::GetEvent 轮询事件队列,命中 id == Exception 时把 sparam 打印出来,就能看到诸如“'1291,32' 值对于 'Value' 是不可接受的,应在 Minimum 到 Maximum 范围内”的明文。另一种常见情况是往不存在的元素名发事件,或给 NumericUpDown 发它不支持的 AddItem,回传会直接点名元素与事件不匹配。 下面这段 NumericSet 故意注释掉了设置最大值的那一行,跑起来就会触发上面的越界异常;事件读取循环里对 Exception 的分支是定位问题的核心。把注释恢复,即补上 NumericMaxChange 的 SendEvent,黄金价格就能正常写入。
<span class="keyword">class="type">void</span> NumericSet(<span class="keyword">class="type">class="kw">string</span> name, <span class="keyword">class="type">long</span> digits, <span class="keyword">class="type">class="kw">double</span> init, <span class="keyword">class="type">class="kw">double</span> max, <span class="keyword">class="type">class="kw">double</span> min, <span class="keyword">class="type">class="kw">double</span> step) { <span class="keyword">class="type">int</span> id_foramt_change = NumericFormatChange; <span class="keyword">class="type">int</span> id_change = NumericChange; <span class="keyword">class="type">int</span> id_max = NumericMaxChange; <span class="keyword">class="type">int</span> id_min = NumericMinChange; <span class="keyword">class="type">long</span> lparam = <span class="number">class="num">0</span>; <span class="keyword">class="type">class="kw">double</span> dparam = <span class="number">class="num">0.0</span>; <span class="keyword">class="type">class="kw">string</span> sparam = <span class="class="type">class="kw">string">""</span>; <span class="comment" style="background-class="type">color:rgb(class="num">255, class="num">242, class="num">153);">class=class="str">"cmt">// GuiController::SendEvent(name, id_max, lparam, max, sparam);</span> GuiController::SendEvent(name, id_min, lparam, min, sparam); GuiController::SendEvent(name, id_change, lparam, init, sparam); GuiController::SendEvent(name, id_foramt_change, digits, step, sparam); } <span class="comment">class=class="str">"cmt">//-- 根据计时器取得新的事件</span> <span class="keyword">for</span>(<span class="keyword">class="kw">static</span> <span class="keyword">class="type">int</span> i = <span class="number">class="num">0</span>; i < GuiController::EventsTotal(); i++) { <span class="keyword">class="type">int</span> id; <span class="keyword">class="type">class="kw">string</span> el_name; <span class="keyword">class="type">long</span> lparam; <span class="keyword">class="type">class="kw">double</span> dparam; <span class="keyword">class="type">class="kw">string</span> sparam; GuiController::GetEvent(i, el_name, id, lparam, dparam, sparam); ... <span class="keyword">if</span>(id == Exception) <span class="functions">printf</span>(<span class="class="type">class="kw">string">"Unexpected exception: "</span> + sparam); } 意外异常:“class="num">1291,class="num">32”值对于“Value”是不可接受的,"Value”应在“Minimum”到“Maximum”的范围内。 参数名称: Value 意外异常: &class="macro">#x27;class="num">1291,class="num">06&class="macro">#x27; 对于 &class="macro">#x27;Value&class="macro">#x27; 是不可接受的,"Value”应在“Minimum”到“Maximum”的范围内。 参数名称: Value GuiController::SendEvent(<span class="class="type">class="kw">string">"empty"</span>, ElementEnable, <span class="number">class="num">0</span>, <span class="number">class="num">0.0</span>, <span class="class="type">class="kw">string">""</span>); 意外异常: SendEvent:未找到名为“empty”的元素 GuiController::SendEvent(<span class="class="type">class="kw">string">"StopLoss"</span>, AddItem, <span class="number">class="num">0</span>, <span class="number">class="num">0.0</span>, <span class="class="type">class="kw">string">"New Text"</span>); 意外异常:元素“StopLos”不支持“Add Item”事件
◍ GuiController 图形元素与通用事件速查
在 MT5 里用 GuiController 搭界面,最怕边写边翻文档。把支持的图形元素和它们的主要事件 ID 固定成一张表,配合 SendEvent / GetEvent 的调用范式,能直接照抄进 EA 或脚本。 消息框靠 MessageBox 事件弹锁屏提示;页面切换看 TabIndexChange,复选框、单选、组合框分别对应 CheckBoxChange、RadioButtonChange、ComboBoxChange。数字调节框会同时抛出 NumericChange、NumericFormatChange、NumericMaxChange、NumericMinChange 四个 ID,日期时间选择器是 DateTimePickerChange,垂直滚动条是 ScrollChange,文本框和标签都走 TextChange,按钮则是 ClickOnElement。 除了具体控件,GuiController 还有三层通用事件:ElementHide 隐藏或恢复元素、ElementEnable 启用或禁用、AddItem 往目标里塞子项,以及调用 CLR 出错时的异常事件。下面这段代码演示了在 OnTimer 里轮询 EventsTotal,捕获 MessageBox 事件后回弹一个严重错误框。 别把事件 ID 当摆设 实际项目里,NumericChange 和 NumericFormatChange 经常同时触发,若只在 Change 里读 dparam 而忽略格式事件,界面显示可能和内部值偏差 4 位小数。建议用 DoubleToString(dparam,4) 统一截断再写逻辑。
class="type">class="kw">string msg = "!!!|操作被取消|严重错误"; GuiController::SendEvent("ButtonForm", MessageBox, LockControl, OK, msg); class="type">void OnTimer { class=class="str">"cmt">//... class=class="str">"cmt">//-- 根据计时器取得新的事件 for(class="kw">static class="type">int i = class="num">0; i < GuiController::EventsTotal(); i++) { class="type">int id; class="type">class="kw">string el_name; class="type">long lparam; class="type">class="kw">double dparam; class="type">class="kw">string sparam; GuiController::GetEvent(i, el_name, id, lparam, dparam, sparam); if(id == MessageBox) { class="type">class="kw">string msg = "!!!|操作被取消|严重错误"; GuiController::SendEvent("ButtonForm", MessageBox, LockControl, OK, msg); } } } class="type">class="kw">string msg = "!!!|操作被取消|严重错误"; GuiController::SendEvent("ButtonForm", MessageBox, LockControl, OK, msg); GuiController::GetEvent(i, el_name, id, lparam, dparam, sparam); printf("选择了新页面. 索引: " + (class="type">class="kw">string)lparam + " Name: " + sparam); GuiController::GetEvent(i, el_name, id, lparam, dparam, sparam); printf("选择了" + sparam + " " + lparam); GuiController::GetEvent(i, el_name, id, lparam, dparam, sparam); if(lparam == true) printf("您选择了 " + sparam); else printf("您取消选择了 " + sparam); GuiController::GetEvent(i, el_name, id, lparam, dparam, sparam); printf("组合框 &class="macro">#x27;" + el_name + "&class="macro">#x27; 已改变 " + sparam); GuiController::GetEvent(i, el_name, id, lparam, dparam, sparam); printf("数值 &class="macro">#x27;" + el_name + "&class="macro">#x27; 已改变, 新值: " + DoubleToString(dparam, class="num">4)); GuiController::GetEvent(i, el_name, id, lparam, dparam, sparam); printf("用户设置了新 class="type">class="kw">datetime 值: " + TimeToString((class="type">class="kw">datetime)lparam)); GuiController::GetEvent(i, el_name, id, lparam, dparam, sparam); printf("垂直滚动部分有了新数值: " + (class="type">class="kw">string)lparam); GuiController::GetEvent(i, el_name, id, lparam, dparam, sparam); printf("输入了新数值: " + sparam); GuiController::GetEvent(i, el_name, id, lparam, dparam, sparam); printf("按钮 " + sparam + " 被按下"); GuiController::GetEvent(i, el_name, id, lparam, dparam, sparam); printf("标签有了新的文字: " + sparam); GuiController::SendEvent("HideGroup", ElementHide, true, class="num">0.0, ""); GuiController::SendEvent("HideGroup", ElementEnable, true, class="num">0.0, ""); GuiController::ShowForm(assembly, "SendOrderForm"); for(class="type">int i = class="num">0; i < SymbolsTotal(true); i++) GuiController::SendEvent("SymbolComboBox", AddItem, class="num">0, class="num">0, SymbolName(i, true)); GuiController::GetEvent(i, el_name, id, lparam, dparam, sparam); printf("Unexpected exception: " + sparam);
「把库跑起来之前先改这条路径」
这套 GuiController 的核心价值不在窗体控件本身,而在于用 C# 写界面、用 MQL5 调 dll 的分工模式。所附 Source.zip 约 50.14 KB,里面三个工程齐全:EA 拉起带控件的窗体、DemoForm.exe 的构建集、以及编译好的 guicontroller.dll 和 C# 源码。 直接抄 demo 最容易踩的坑是绝对路径。示范 EA 在「assembly」参数里写死了作者机器上的窗体路径,你本地照搬必报找不到文件,必须换成自己 PC 上的实际路径才能启动。 社区里有人把面板直接并进基础库、绕开外部库限制传到了 CodeBase,也有人在 .NET Core 下问能否转 WPF——说明这套桥接思路在 2024 年仍有人折腾。外汇和贵金属行情高波动,拿它做盯盘面板可以,但别指望界面库能替你管风险。