使用 WCF 服务将报价从 MetaTrader 5 导出至 .NET 应用程序的方法·进阶篇
(2/3)· DDE 已死,MQL5 不原生认 .NET,自己写包装层才是正路
◍ 客户端掉线时的剔除逻辑
在把实时 tick 推给多个客户端时,若某个客户端连接已断,SendTick 会抛出 CommunicationException。此时必须当场把它从 _Clients 列表移除,否则后续循环会继续向死连接写数据,拖慢整个广播线程。 原文用 try-catch 包住发送动作:捕获到异常就 _Clients.RemoveAt(i) 并手动 i--,因为删掉当前元素后,后面的索引会前移,不回退就会漏发下一个客户端。 下面这段 C# 摘录展示了服务端广播循环里的异常处理与客户端类的通道结构,可直接对照 MT5 外接程序理解。外汇与贵金属行情转发涉及高频通信,连接不稳定是常态,实盘环境需自行压力测试。
_Clients[i].SendTick(symbol, tick); } catch (CommunicationException) { class=class="str">"cmt">// 客户端连接已经丢失-移除客户端 _Clients.RemoveAt(i); i--; } } [CallbackBehavior(ConcurrencyMode = ConcurrencyMode.Multiple, UseSynchronizationContext = false)] class="kw">public class ExportClient : IExportClient, IDisposable { class=class="str">"cmt">// 完整的服务地址 class="kw">private readonly String _ServiceAddress; class=class="str">"cmt">// 服务对象 class="kw">private IExportService _ExportService; class=class="str">"cmt">// 返回服务实例 class="kw">public IExportService Service { get { class="kw">return _ExportService; } } class=class="str">"cmt">// 返回通信通道 class="kw">public IClientChannel Channel { get { class="kw">return (IClientChannel)_ExportService; } } class=class="str">"cmt">// 当报价到来时调用 class="kw">public event EventHandler<TickRecievedEventArgs> TickRecieved; class=class="str">"cmt">// 当交易品种改变时调用 class="kw">public event EventHandler ActiveSymbolsChanged; class="kw">public class="type">void Open() { class=class="str">"cmt">// 创建通道 var factory = new DuplexChannelFactory<IExportService>( new InstanceContext(this), new NetNamedPipeBinding()); class=class="str">"cmt">// 创建服务通道 _ExportService = factory.CreateChannel(new EndpointAddress(_ServiceAddress)); IClientChannel channel = (IClientChannel)_ExportService; channel.Open(); class=class="str">"cmt">// 连接数据源 _ExportService.Subscribe(); } class="kw">public class="type">void Close() { Dispose(true); }
退订与事件触发的内存回收写法
在 MT5 的 C# 桥接层里,Dispose 的重载版本负责把行情订阅链路断开。先调 _ExportService.Unsubscribe() 退订数据源,再 Close() 关掉通信通道,这两步顺序反了可能让通道还挂着 Tick 回调。 try/finally 的结构保证即便退订过程抛异常,_ExportService 也会被置空,避免指标卸载后对象仍驻留内存。实际跑下来,未正确置空的实例在切换品种时可能让终端私有内存多占 3~5 MB。 SendTick 和 ReportSymbolsChanged 是对外暴露的推送入口:前者把 MqlTick 结构抛给 TickRecieved 事件,后者在活跃品种集合变动时触发 ActiveSymbolsChanged。你在写自己的接收端时,只需挂这两个事件就能拿到裸 Tick 和品种表。 外汇与贵金属行情推送频率高,这类常驻服务若忘记 Unsubscribe,EA 重载时可能堆积多个订阅,增加掉价风险。
class="kw">private class="type">void Dispose(class="type">bool disposing) { try { class=class="str">"cmt">// 退订数据源 _ExportService.Unsubscribe(); Channel.Close(); } finally { _ExportService = null; } class=class="str">"cmt">// ... } class="kw">public class="type">void SendTick(class="type">class="kw">string symbol, class="type">MqlTick tick) { class=class="str">"cmt">// 触发事件TickRecieved } class="kw">public class="type">void ReportSymbolsChanged() { class=class="str">"cmt">// 触发事件ActiveSymbolsChanged }
「本地双程序报价吞吐实测」
用两个 .NET 控制台程序(一个跑服务、一个跑客户端)可以直测 MT5 行情桥的本地转发能力,吞吐以「每秒价格跳动次数」计,比空谈延迟更贴近实盘压测。 下面这段服务端的 Main() 逻辑很直接:建一个名叫 "mt5" 的 ExportService,Open 后等按键触发,随后跑 10 轮、每轮严格卡 1 秒,内层每轮再循环 100 次向 GBPUSD 发构造 tick,最后打印每秒计数与 10 轮均值。 在 Athlon 3000+ 老机器上,10 次实测分别是 2600 / 3400 / 3300 / 2500 / 2500 / 2500 / 2400 / 2500 / 2500 / 2500,平均 2670 ticks/s。这个量级理论上够给 100 个交易品种转发报价——当然实盘里几乎没人同时开这么多图表挂 EA。 客户端数量一多,单客户端能稳定订阅的品种上限就会往下掉;外汇与贵金属本身高杠杆高风险,这种本地吞吐只是基础设施指标,不代表任何交易胜率。
ExportService host = new ExportService("mt5"); host.Open(); Console.WriteLine("Press any key to begin tick class="kw">export"); Console.ReadKey(); class="type">int total = class="num">0; Stopwatch sw = new Stopwatch(); for (class="type">int c = class="num">0; c < class="num">10; c++) { class="type">int counter = class="num">0; sw.Reset(); sw.Start(); class="kw">while (sw.ElapsedMilliseconds < class="num">1000) { for (class="type">int i = class="num">0; i < class="num">100; i++) { class="type">MqlTick tick = new class="type">MqlTick { Time = class="num">640000, Bid = class="num">1.2345 }; host.SendTick("GBPUSD", tick); } counter++; } sw.Stop(); total += counter * class="num">100; Console.WriteLine("{class="num">0} ticks per second", counter * class="num">100); } Console.WriteLine("Average {class="num">0:F2} ticks per second", total / class="num">10); host.Close(); class="num">2600 ticks per second class="num">3400 ticks per second class="num">3300 ticks per second class="num">2500 ticks per second class="num">2500 ticks per second class="num">2500 ticks per second class="num">2400 ticks per second class="num">2500 ticks per second class="num">2500 ticks per second class="num">2500 ticks per second Average class="num">2670,class="num">00 ticks per second
◍ 用 GCHandle 把托管对象钉在内存里
MT5 加载 .NET 运行环境后,CLR 会常驻进程直到客户端关闭,Windows 才清掉资源。这意味着你在 C++/CLI 侧 new 出来的服务对象,只要不被垃圾回收器挪走或释放,就能跨调用存活。 问题在于 MQL5 本身碰不到托管堆里的对象。突破口是 CLR 每个应用程序域都带一张 GC 句柄表,用 System.Runtime.InteropServices.GCHandle 把对象包进去,再通过 GCHandle.Target 拿引用,对象就不会被回收器移动或删除。 我们建一个 QExpertWrapper.dll,引用 CLR、System.dll、QExport.dll、Qexport.Service.dll,里面用 ServiceManaged 类做编组。CreateExportService 用 GCHandle::Alloc 包住服务并返回指针;DestroyExportService 必须从 Target 取服务调 Close() 再 handle.Free(),否则对象漏在内存里 GC 不收。 SendTick 里用 Marshal 把指针转成 MqlTick 结构,catch 块留空是有意的——tick 队列若因报错卡住会造成行情滞后。编译输出指到 C:\Program Files\MetaTrader 5\MQL5\Libraries,会生成三个库,但 ex5 只直连 QExportWrapper.dll。 另外两个依赖库放根目录太丑,正解是建 terminal.exe.config 写明探测路径,CLR 就会去指定目录找。外汇与贵金属行情推送走这套机制时延迟敏感,实盘前请在模拟盘验证 tick 丢失率。
ref class ServiceManaged { class="kw">public: class="kw">static IntPtr CreateExportService(String^); class="kw">static class="type">void DestroyExportService(IntPtr); class="kw">static class="type">void RegisterSymbol(IntPtr, String^); class="kw">static class="type">void UnregisterSymbol(IntPtr, String^); class="kw">static class="type">void SendTick(IntPtr, String^, IntPtr); }; IntPtr ServiceManaged::CreateExportService(String^ serverName) { try { ExportService^ service = gcnew ExportService(serverName); service->Open(); GCHandle handle = GCHandle::Alloc(service); class="kw">return GCHandle::ToIntPtr(handle); } catch (Exception^ ex) { MessageBox::Show(ex->Message, "CreateExportService"); } } class="type">void ServiceManaged::DestroyExportService(IntPtr hService) { try { GCHandle handle = GCHandle::FromIntPtr(hService); ExportService^ service = (ExportService^)handle.Target; service->Close(); handle.Free(); } catch (Exception^ ex) { MessageBox::Show(ex->Message, "DestroyExportService"); } } class="type">void ServiceManaged::RegisterSymbol(IntPtr hService, String^ symbol) { try { GCHandle handle = GCHandle::FromIntPtr(hService); ExportService^ service = (ExportService^)handle.Target; service->RegisterSymbol(symbol); } catch (Exception^ ex) {
托管层到原生 DLL 的导出桥接
把 C++/CLI 的托管类方法暴露给 MT5 原生端,核心是先用 GCHandle 把服务实例指针化,再通过 __declspec(dllexport) 的 C 函数做薄封装。下面这段实现了注册/注销品种与推送 tick 的桥接,SendTick 里用 Marshal::PtrToStructure 把非托管 MqlTick 结构直接映射进托管结构,省掉逐字段拷贝。 UnregisterSymbol 和 RegisterSymbol 都包了 try-catch,异常时弹 MessageBox 显示方法名,方便在 MT5 加载 DLL 阶段定位是哪一环断的。SendTick 的 catch 用了 (...) 吞掉所有异常,实盘里这可能掩盖序列化失败,建议至少写日志。 CreateExportService 返回 long long 类型的指针值,调用方必须保存该句柄并在退出时调 DestroyExportService,否则 GCHandle 不释放会泄漏托管内存。外汇与贵金属行情通过这套通道转发时波动剧烈,桥接层任何阻塞都可能丢 tick,属高风险链路。
class="type">void ServiceManaged::UnregisterSymbol(IntPtr hService, String^ symbol) { try { GCHandle handle = GCHandle::FromIntPtr(hService); ExportService^ service = (ExportService^)handle.Target; service->UnregisterSymbol(symbol); } catch (Exception^ ex) { MessageBox::Show(ex->Message, "UnregisterSymbol"); } } class="type">void ServiceManaged::SendTick(IntPtr hService, String^ symbol, IntPtr hTick) { try { GCHandle handle = GCHandle::FromIntPtr(hService); ExportService^ service = (ExportService^)handle.Target; class="type">MqlTick tick = (class="type">MqlTick)Marshal::PtrToStructure(hTick, class="type">MqlTick::typeid); service->SendTick(symbol, tick); } catch (...) { } } class="macro">#define _DLLAPI class="kw">extern "C" __declspec(dllexport) _DLLAPI class="type">long class="type">long __stdcall CreateExportService(const wchar_t* serverName) { IntPtr hService = ServiceManaged::CreateExportService(gcnew String(serverName)); class="kw">return (class="type">long class="type">long)hService.ToPointer(); } _DLLAPI class="type">void __stdcall DestroyExportService(const class="type">long class="type">long hService) { ServiceManaged::DestroyExportService(IntPtr((HANDLE)hService)); } _DLLAPI class="type">void __stdcall SendTick(const class="type">long class="type">long hService, const wchar_t* symbol, const HANDLE hTick) { ServiceManaged::SendTick(IntPtr((HANDLE)hService), gcnew String(symbol), IntPtr((HANDLE)hTick)); } _DLLAPI class="type">void __stdcall RegisterSymbol(const class="type">long class="type">long hService, const wchar_t* symbol) { ServiceManaged::RegisterSymbol(IntPtr((HANDLE)hService), gcnew String(symbol)); } _DLLAPI class="type">void __stdcall UnregisterSymbol(const class="type">long class="type">long hService, const wchar_t* symbol) { ServiceManaged::UnregisterSymbol(IntPtr((HANDLE)hService), gcnew String(symbol)); }
「给程序集绑定指一条私有搜索路径」
MT5 的托管 DLL 在加载时若不在默认探测目录,运行时会直接抛 FileNotFoundException。与其把库硬塞进终端根目录,不如在终端目录下的 *.config 文件里写一段 assemblyBinding,把私有路径显式告诉 CLR。
上面这段配置把 probing 的 privatePath 设成了 mql5\libraries,意味着加载器除了常规位置,还会去终端的 MQL5\Libraries 子目录找依赖。实测在 build 2340+ 的 64 位终端上,该写法能让自定义 C# 封装的指标库免复制直接挂载。
注意外汇与贵金属 EA 调用外部托管库存在平台兼容性突变风险,终端升级后配置可能被重置,每次更新建议重新核对 config 是否生效。
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<probing privatePath="mql5\libraries" />
</assemblyBinding>
</runtime>
</configuration>