为什么选择 .NET?
对有着长期 .NET 编程经验的笔者而言,使用该平台实施报价的导出无疑更合理、有趣和容易。遗憾的是,第五版中没有对 .NET 提供任何 MQL5 原生支持。我相信开发人员这样做有其自身的考虑。因此,我们将使用 win32 dll 作为支持 .NET 的包装程序。
为什么选择 WCF?
我之所以选择 Windows Communication Foundation (WCF) 技术是出于以下几点考虑:一方面,它易于扩展和适应,另一方面,我想在繁重的工作下对其进行检测。此外,根据 Microsoft 的说法,WCF 相比 .NET Remoting 在性能上略胜一筹。
让我们思考一下,我们希望从我们的系统中得到什么。我认为,有两个主要的要求:
我们开始吧...
首先,我们创建一个新的类库,并将其命名为 QExport.dll。我们将 MqlTick 结构定义为 DataContract:
[StructLayout(LayoutKind.Sequential)] [DataContract] public struct MqlTick { [DataMember] public Int64 Time { get; set; } [DataMember] public Double Bid { get; set; } [DataMember] public Double Ask { get; set; } [DataMember] public Double Last { get; set; } [DataMember] public UInt64 Volume { get; set; } }
然后我们将定义服务的契约。我不喜欢使用配置类和生成的代理类,所以它们不会出现在本文中。
让我们根据上述要求定义第一个服务器契约:
[ServiceContract(CallbackContract = typeof(IExportClient))] public interface IExportService { [OperationContract] void Subscribe(); [OperationContract] void Unsubscribe(); [OperationContract] String[] GetActiveSymbols(); }
正如我们看到的,有一套订阅和取消订阅服务器通知的标准方案。下面是操作细节的简短说明:
操作 | 描述 |
---|---|
Subscribe() | 订阅价格跳动导出 |
Unsubscribe() | 取消订阅价格跳动导出 |
GetActiveSymbols() | 返回导出的交易品种列表 |
以下信息应发送至客户端回调:报价本身以及有关导出交易品种列表变更的通知接下来我们将要求的操作定义为“单向操作”以提高性能:
[ServiceContract] public interface IExportClient { [OperationContract(IsOneWay = true)] void SendTick(String symbol, MqlTick tick); [OperationContract(IsOneWay = true)] void ReportSymbolsChanged(); }
操作 | 描述 |
---|---|
SendTick(String, MqlTick) | 发送价格跳动 |
ReportSymbolsChanged() | 通知客户端有关导出交易品种列表的变更 |
让我们通过服务器契约实施为服务创建名为 Qexport.Service.dll 的新的结构。
我们选择 NetNamedPipesBinding 进行绑定,因为相比标准绑定,它具有最优的性能。如果我们需要广播报价,例如通过网络,则应使用 NetTcpBinding。
以下是服务器契约实施的一些细节:
类定义。首先,应使用以下修饰符将其标记为 ServiceBehavior 属性:
ExportService 本身包含两个接口: IExportService 和 IDisposable。前者实施所有服务函数,后者实施 .NET 资源释放的标准模型。
[ServiceBehavior(InstanceContextMode = InstanceContextMode.Single, ConcurrencyMode = ConcurrencyMode.Multiple, UseSynchronizationContext = false, IncludeExceptionDetailInFaults = true)] public class ExportService : IExportService, IDisposable {
对服务变量的描述如下所示:
// net.pipe形式的服务的完整地址 ://localhost/server_name private readonly String _ServiceAddress; // 服务主机 private ServiceHost _ExportHost; // 激活的客户端回调Collection private Collection<IExportClient> _Clients = new Collection<IExportClient>(); // 激活交易品种列表 private List<String> _ActiveSymbols = new List<string>(); // 用于锁定的对象 private object lockClients = new object();
让我们定义 Open() 和 Close() 方法,这些方法用于启动和关闭我们的服务:
public void Open() { _ExportHost = new ServiceHost(this); // 服务节点 _ExportHost.AddServiceEndpoint(typeof(IExportService), // 协议 new NetNamedPipeBinding(), // 绑定 new Uri(_ServiceAddress)); // 地址 // 去除队列16个请求的限制 ServiceThrottlingBehavior bhvThrot = new ServiceThrottlingBehavior(); bhvThrot.MaxConcurrentCalls = Int32.MaxValue; _ExportHost.Description.Behaviors.Add(bhvThrot); _ExportHost.Open(); } public void Close() { Dispose(true); } private void Dispose(bool disposing) { try { // 关闭每个客户端的通道 // ... // 关闭主机 _ExportHost.Close(); } finally { _ExportHost = null; } // ... }
接下来,是 IExportService 方法的实施:
public void Subscribe() { // 获取回调通道 IExportClient cl = OperationContext.Current.GetCallbackChannel<IExportClient>(); lock (lockClients) _Clients.Add(cl); } public void Unsubscribe() { // 获取回调通道 IExportClient cl = OperationContext.Current.GetCallbackChannel<IExportClient>(); lock (lockClients) _Clients.Remove(cl); } public String[] GetActiveSymbols() { return _ActiveSymbols.ToArray(); }
现在,我们需要添加发送价格跳动和注册及删除导出交易品种的方法。
public void RegisterSymbol(String symbol) { if (!_ActiveSymbols.Contains(symbol)) _ActiveSymbols.Add(symbol); // 向所有客户端发送激活的交易品种改变的通知 //... } public void UnregisterSymbol(String symbol) { _ActiveSymbols.Remove(symbol); // 向所有客户端发送激活的交易品种改变的通知 //... } public void SendTick(String symbol, MqlTick tick) { lock (lockClients) for (int i = 0; i < _Clients.Count; i++) try { _Clients[i].SendTick(symbol, tick); } catch (CommunicationException) { // 客户端连接已经丢失-移除客户端 _Clients.RemoveAt(i); i--; } }
我们总结了主要服务器函数列表,如下所示(仅限我们需要的函数):
方法 | 描述 |
---|---|
Open() | 运行服务器 |
Close() | 停止服务器 |
RegisterSymbol(String) | 将交易品种添加至导出交易品种列表 |
UnregisterSymbol(String) | 从导出交易品种列表删除交易品种 |
GetActiveSymbols() | 返回导出的交易品种数量 |
SendTick(String, MqlTick) | 发送价格跳动至客户端 |
3. 客户端实施
我认为我们已经对服务器进行了充分的讨论,接下来是时候考虑客户端了。让我们来构建 Qexport.Client.dll。客户端契约将在此实施。首先,应使用定义其行为的 CallbackBehavior 属性对其加以标记。它具有以下修饰符:
对于服务器情形,客户端同样部署有两个接口:IExportClient 和 IDisposable:
[CallbackBehavior(ConcurrencyMode = ConcurrencyMode.Multiple, UseSynchronizationContext = false)] public class ExportClient : IExportClient, IDisposable {
我们来描述服务变量:
// 完整的服务地址 private readonly String _ServiceAddress; // 服务对象 private IExportService _ExportService; // 返回服务实例 public IExportService Service { get { return _ExportService; } } // 返回通信通道 public IClientChannel Channel { get { return (IClientChannel)_ExportService; } }
现在,我们来为回调方法创建事件。这要求客户端应用程序能够订阅事件和获得有关客户端状态变更的通知。
// 当报价到来时调用 public event EventHandler<TickRecievedEventArgs> TickRecieved; // 当交易品种改变时调用 public event EventHandler ActiveSymbolsChanged;
还需为客户端定义 Open() 和 Close() 方法:
public void Open() { // 创建通道 var factory = new DuplexChannelFactory<IExportService>( new InstanceContext(this), new NetNamedPipeBinding()); // 创建服务通道 _ExportService = factory.CreateChannel(new EndpointAddress(_ServiceAddress)); IClientChannel channel = (IClientChannel)_ExportService; channel.Open(); // 连接数据源 _ExportService.Subscribe(); } public void Close() { Dispose(true); } private void Dispose(bool disposing) { try { // 退订数据源 _ExportService.Unsubscribe(); Channel.Close(); } finally { _ExportService = null; } // ... }
请注意,连接源以及从源断开连接是在客户端开启或关闭时调用,因此没有必要直接调用。
现在,我们来编写客户端契约。它的实施会导致以下事件的生成:
public void SendTick(string symbol, MqlTick tick) { // 触发事件TickRecieved } public void ReportSymbolsChanged() { // 触发事件ActiveSymbolsChanged }
最后,客户端的主要属性和方法定义如下:
属性 | 描述 |
---|---|
Service | 服务通信信道 |
Channel | 服务契约 IExportService 的实例 |
方法 | 描述 |
---|---|
Open() | 连接至服务器 |
Close() | 从服务器断开连接 |
事件 | 描述 |
---|---|
TickRecieved | 接收到新报价时生成 |
ActiveSymbolsChanged | 活动交易品种列表变更时生成 |
对我而言,衡量两个 .NET 应用程序间的传送速度十分有趣;事实上,它的吞吐量通过每秒价格跳动次数衡量。我编写了几个控制台应用程序来衡量服务性能:第一个应用程序用于服务器,另外一个用于客户端。我在服务器的 Main() 函数中添加了以下代码:
ExportService host = new ExportService("mt5"); host.Open(); Console.WriteLine("Press any key to begin tick export"); Console.ReadKey(); int total = 0; Stopwatch sw = new Stopwatch(); for (int c = 0; c < 10; c++) { int counter = 0; sw.Reset(); sw.Start(); while (sw.ElapsedMilliseconds < 1000) { for (int i = 0; i < 100; i++) { MqlTick tick = new MqlTick { Time = 640000, Bid = 1.2345 }; host.SendTick("GBPUSD", tick); } counter++; } sw.Stop(); total += counter * 100; Console.WriteLine("{0} ticks per second", counter * 100); } Console.WriteLine("Average {0:F2} ticks per second", total / 10); host.Close();
我们可以看到,代码执行了十个吞吐量测量值。我在我的 Athlon 3000+ 上获得下面的十个测试结果:
2600 ticks per second 3400 ticks per second 3300 ticks per second 2500 ticks per second 2500 ticks per second 2500 ticks per second 2400 ticks per second 2500 ticks per second 2500 ticks per second 2500 ticks per second Average 2670,00 ticks per second
每秒 2500 次价格跳动 - 我认为它足够为 100 个交易品种导出报价(当然,实际上,似乎没有人会打开这么多图表并附加 EA 交易程序)。此外,随着客户端增多,每个客户端的导出交易品种的最大数量将下降。
现在,是时候考虑如何将其连接至客户端了。我们来看一下在 MetaTrader 5 中第一次调用函数的情形:.NET 运行环境 (CLR) 加载至进程,应用程序域默认创建。有趣的是,CLR 在代码执行后并未卸载。
从进程卸载 CLR 的唯一方法是将其终止(关闭客户端),这将迫使 Windows 清除所有进程资源。因此,我们可以创建我们的对象,这些对象将一直存在,直到它们被“垃圾回收器”销毁或应用程序域卸载。
您可以说这看上去不错,但即使我们防止对象被“垃圾回收器”销毁,我们也无法从 MQL5 访问对象。幸运的是,这样的访问可以轻松实现。诀窍如下:对应于每个应用程序域都存在一个“垃圾回收器”句柄表(GC 句柄表),应用程序使用该表来追踪对象的使用期以及手动控制对象。
应用程序通过使用类型System.Runtime.InteropServices.GCHandle 从表中添加和删除元素。我们要做的就是使用这样的描述符包装我们的对象,并且我们可以通过属性 GCHandle.Target 对其进行访问。因此,我们可以获得对对象 GCHandle 的引用,该对象位于句柄表中并保证不会被“垃圾回收器”移动或删除。由于通过描述符引用,包装的对象还会避免回收。
现在到了我们实践检验理论时候了。为此,我们创建一个名为 QExpertWrapper.dll 的新 win32 dll,并将 CLR 支持、System.dll、QExport.dll、Qexport.Service.dll 添加至结构引用。我们还需要创建一个辅助类 ServiceManaged 用于管理 - 执行编组,通过句柄等接收对象。
ref class ServiceManaged { public: static IntPtr CreateExportService(String^); static void DestroyExportService(IntPtr); static void RegisterSymbol(IntPtr, String^); static void UnregisterSymbol(IntPtr, String^); static void SendTick(IntPtr, String^, IntPtr); };
让我们来看一看这些方法的实施。CreateExportService 方法创建服务,通过使用 GCHandle.Alloc 将其包装在 GCHandle 中并返回其引用。如果出现故障,将显示出错的 MessageBox 。我将它用于调试,我不确定是不是真有必要,将它留在这里是为了以防万一。
IntPtr ServiceManaged::CreateExportService(String^ serverName) { try { ExportService^ service = gcnew ExportService(serverName); service->Open(); GCHandle handle = GCHandle::Alloc(service); return GCHandle::ToIntPtr(handle); } catch (Exception^ ex) { MessageBox::Show(ex->Message, "CreateExportService"); } }
DestroyExportService 方法获得指向服务的 GCHandle 的指针,从目标属性获得服务并调用其方法 Close()。通过调用其方法 Free() 来释放服务对象是很重要的。否则它将留在内存中,“垃圾回收器”不会删除它。
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"); } }
RegisterSymbol 方法将交易品种添加至导出交易品种列表:
void ServiceManaged::RegisterSymbol(IntPtr hService, String^ symbol) { try { GCHandle handle = GCHandle::FromIntPtr(hService); ExportService^ service = (ExportService^)handle.Target; service->RegisterSymbol(symbol); } catch (Exception^ ex) { MessageBox::Show(ex->Message, "RegisterSymbol"); } }
UnregisterSymbol 方法从交易品种列表删除交易品种:
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"); } }
接下来是 SendTick 方法。我们可以看到,指针通过 Marshal 类转换成 MqlTick 结构。另一要点:catch 代码块中没有任何代码 - 这样做是为了在发生错误时避免一般价格跳动队列的滞后。
void ServiceManaged::SendTick(IntPtr hService, String^ symbol, IntPtr hTick) { try { GCHandle handle = GCHandle::FromIntPtr(hService); ExportService^ service = (ExportService^)handle.Target; MqlTick tick = (MqlTick)Marshal::PtrToStructure(hTick, MqlTick::typeid); service->SendTick(symbol, tick); } catch (...) { } }
接下来我们讨论函数的实施,这些函数将从我们的 ex5 程序中调用:
#define _DLLAPI extern "C" __declspec(dllexport) // --------------------------------------------------------------- // 创建并打开服务 // 返回指针 // --------------------------------------------------------------- _DLLAPI long long __stdcall CreateExportService(const wchar_t* serverName) { IntPtr hService = ServiceManaged::CreateExportService(gcnew String(serverName)); return (long long)hService.ToPointer(); } // ----------------------------------------- ---------------------- // 关闭服务 // --------------------------------------------------------------- _DLLAPI void __stdcall DestroyExportService(const long long hService) { ServiceManaged::DestroyExportService(IntPtr((HANDLE)hService)); } // --------------------------------------------------------------- // 发送报价 // --------------------------------------------------------------- _DLLAPI void __stdcall SendTick(const long long hService, const wchar_t* symbol, const HANDLE hTick) { ServiceManaged::SendTick(IntPtr((HANDLE)hService), gcnew String(symbol), IntPtr((HANDLE)hTick)); } // --------------------------------------------------------------- // 注册输出的交易品种 // --------------------------------------------------------------- _DLLAPI void __stdcall RegisterSymbol(const long long hService, const wchar_t* symbol) { ServiceManaged::RegisterSymbol(IntPtr((HANDLE)hService), gcnew String(symbol)); } // --------------------------------------------------------------- // 将交易品种从输出交易品种列表中移除 // --------------------------------------------------------------- _DLLAPI void __stdcall UnregisterSymbol(const long long hService, const wchar_t* symbol) { ServiceManaged::UnregisterSymbol(IntPtr((HANDLE)hService), gcnew String(symbol)); }
代码已准备就绪,现在我们需要对其进行编译和构建。在项目选项中,我们将输出目录指定为 "C:\Program Files\MetaTrader 5\MQL5\Libraries"。编译后,指定的文件夹中将出现三个库。
mql5 程序仅使用其中一个库,即 QExportWrapper.dll,另外的两个库由它使用。由于这个原因,我们需要将库 Qexport.dll 和 Qexport.Service.dll 放在 MetaTrader 的根文件夹中。这不是很方便。
解决方案是创建配置文件,并为库指定路径。我们在 MetaTrader 的根文件夹中创建名为 terminal.exe.config 的文件,并写下以下内容:
<configuration> <runtime> <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1"> <probing privatePath="mql5\libraries" /> </assemblyBinding> </runtime> </configuration>
好了,现在 CLR 将在文件夹中搜索我们指定的库。
最后,我们讨论以 mql5 进行的服务器部分编程。我们创建新文件 QService.mqh 并定义 QExpertWrapper.dll 的导入函数:
#import "QExportWrapper.dll" long CreateExportService(string); void DestroyExportService(long); void RegisterSymbol(long, string); void UnregisterSymbol(long, string); void SendTick(long, string, MqlTick&); #import
MQL5 具有类,类是将所有逻辑囊括在内的理想功能,大幅简化了相应工作和对代码的理解。因此,我们来设计一个类,作为库方法的外壳。
此外,为避免为每个交易品种创建服务的情形,我们可以对具有该名称的运行服务进行检查,并且我们将解决这种情况。提供此信息的理想方法是全局变量,原因如下:
因此,我们创建类 Qservice:
class QService { private: // 服务指针 long hService; // 服务名称 string serverName; // 服务的全局变量的名称 string gvName; // 服务是否关闭的标识 bool wasDestroyed; // 进入关键环节 void EnterCriticalSection(); // 退出关键环节 void LeaveCriticalSection(); public: QService(); ~QService(); // 打开服务 void Create(const string); // 关闭服务 void Close(); // 发送报价 void SendTick(const string, MqlTick&); }; //-------------------------------------------------------------------- QService::QService() { wasDestroyed = false; } //-------------------------------------------------------------------- QService::~QService() { // 如果没有被销毁则关闭 if (!wasDestroyed) Close(); } //-------------------------------------------------------------------- QService::Create(const string serviceName) { EnterCriticalSection(); serverName = serviceName; bool exists = false; string name; // 用此名称检查活动的服务 for (int i = 0; i < GlobalVariablesTotal(); i++) { name = GlobalVariableName(i); if (StringFind(name, "QService|" + serverName) == 0) { exists = true; break; } } if (!exists) // 如果不存在 { // 启动服务 hService = CreateExportService(serverName); // 添加一个全局变量 gvName = "QService|" + serverName + ">" + (string)hService; GlobalVariableTemp(gvName); GlobalVariableSet(gvName, 1); } else // 服务存在 { gvName = name; // 服务的句柄 hService = (int)StringSubstr(gvName, StringFind(gvName, ">") + 1); // 通过此脚本来使用此服务 GlobalVariableSet(gvName, NormalizeDouble(GlobalVariableGet(gvName), 0) + 1); } // 注册图表上的交易品种 RegisterSymbol(hService, Symbol()); LeaveCriticalSection(); } //-------------------------------------------------------------------- QService::Close() { EnterCriticalSection(); // 通知此脚本不使用此服务 // GlobalVariableSet(gvName, NormalizeDouble(GlobalVariableGet(gvName), 0) - 1); // 如果没有任何脚本使用,关闭服务 if (NormalizeDouble(GlobalVariableGet(gvName), 0) < 1.0) { GlobalVariableDel(gvName); DestroyExportService(hService); } else UnregisterSymbol(hService, Symbol()); // 未注册的交易品种 wasDestroyed = true; LeaveCriticalSection(); } //-------------------------------------------------------------------- QService::SendTick(const string symbol, MqlTick& tick) { if (!wasDestroyed) SendTick(hService, symbol, tick); } //-------------------------------------------------------------------- QService::EnterCriticalSection() { while (GlobalVariableCheck("QService_CriticalSection") > 0) Sleep(1); GlobalVariableTemp("QService_CriticalSection"); } //-------------------------------------------------------------------- QService::LeaveCriticalSection() { GlobalVariableDel("QService_CriticalSection"); }
该类包含以下方法:
方法 | 描述 |
---|---|
Create(const string) | 启动服务 |
Close() | 关闭服务 |
SendTick(const string, MqlTick&) | 发送报价 |
亦请注意,私有方法 EnterCriticalSection() 和 LeaveCriticalSection() 允许您运行它们之间的关键代码段。
它可将我们从同时调用函数 Create() 以及为每个 QService 创建新服务的情形中解放出来。
因此,我们已为使用服务对类进行描述,现在我们为报价广播编写“EA 交易”。选择“EA 交易”是因其具有处理所有到达价格跳动的可能性。
//+------------------------------------------------------------------+ //| QExporter.mq5 | //| Copyright GF1D, 2010 | //| garf1eldhome@mail.ru | //+------------------------------------------------------------------+ #property copyright "GF1D, 2010" #property link "garf1eldhome@mail.ru" #property version "1.00" #include "QService.mqh" //--- 输入参数 input string ServerName = "mt5"; QService* service; //+------------------------------------------------------------------+ //| EA交易初始化函数 | //+------------------------------------------------------------------+ int OnInit() { service = new QService(); service.Create(ServerName); return(0); } //+------------------------------------------------------------------+ //| EA交易去初始化函数 | //+------------------------------------------------------------------+ void OnDeinit(const int reason) { service.Close(); delete service; service = NULL; } //+------------------------------------------------------------------+ //| EA的订单函数 | //+------------------------------------------------------------------+ void OnTick() { MqlTick tick; SymbolInfoTick(Symbol(), tick); service.SendTick(Symbol(), tick); } //+------------------------------------------------------------------+
显而易见,如果报价从客户端直接到达,服务的总体性能将下降,因此我很有兴趣对此加以衡量。我肯定总体性能将会下降,因为编组和类型转换会带来不可避免的 CPU 时间损耗。
为此,我编写了一个简单的脚本,和为第一个测试编写的脚本相同。Start() 函数如下所示:
QService* serv = new QService(); serv.Create("mt5"); MqlTick tick; SymbolInfoTick("GBPUSD", tick); int total = 0; for(int c = 0; c < 10; c++) { int calls = 0; int ticks = GetTickCount(); while(GetTickCount() - ticks < 1000) { for(int i = 0; i < 100; i++) serv.SendTick("GBPUSD", tick); calls++; } Print(calls * 100," calls per second"); total += calls * 100; } Print("Average ", total / 10," calls per second"); serv.Close(); delete serv;
我获得如下结果:
1900 calls per second 2400 calls per second 2100 calls per second 2300 calls per second 2000 calls per second 2100 calls per second 2000 calls per second 2100 calls per second 2100 calls per second 2100 calls per second Average 2110 calls per second
2500 价格跳动/秒与 1900 价格跳动/秒。使用来自 MT5 的服务的代价是性能下降 25%,但无论如何,这一性能业已足够。有意思的是,我们发现可使用线程池和静态方法 System.Threading.ThreadPool.QueueUserWorkItem 来提高性能。
使用此方法,我获得高达每秒 10000 次价格跳动的传送速度。但它在严苛测试中的表现并不稳定,因为“垃圾回收器”没有时间来删除对象,使得 MetaTrader 分配的内存快速增长而最后导致崩溃。但这种情况发生在与现实有很大差距的严苛测试中,因此使用线程池并不会带来危险。
我已经使用服务创建了一个价格跳动表的示例。项目已附于档案并命名为 WindowsClient。其运行结果如下所示:
图 1. 含报价表的 WindowsClient 应用程序主窗口
在本文中,我对导出报价至 .NET 应用程序的方法之一进行了说明。所有要求均已实施,现在这些就绪的类可用于您自己的应用程序中。唯一不太方便的是,需要附加脚本至每一必要图表。
目前,我认为此问题可使用 MetaTrader 配置文件加以解决。从另一方面来说,如果您不需要全部的报价,您可以使用脚本来为必要交易品种广播报价。如您所知,市场深度广播甚至双向访问可使用同样的方法实现。
对档案描述如下:
Bin.rar - 具有就绪解决方案的档案。适用于想看看它是如何工作的用户。仍请注意,您的电脑需要安装 .NET Framework 3.5(也许版本 3.0 亦可)。
Src.rar - 项目的完整源代码。要使用它,您需要 MetaEditor 和 Visual Studio 2008。
QExportDemoProfile.rar - 附加脚本至 10 个图表的 Metatrader 配置文件,如图 1 所示。
本社区仅针对特定人员开放
查看需注册登录并通过风险意识测评
5秒后跳转登录页面...
移动端课程