基于快速数学计算的自定义策略测试器

Vasiliy Sokolov | 5 三月, 2018


内容



概述

MetaTrader 5 里提供的策略测试器具有强大的功能, 可以解决各种任务。它既可以用来测试复杂的金融产品篮子策略, 以及含有简单入场和离场规则的单一策略。但是, 如此宏大的功能并非总是有用。我们往往只需要快速检查一个简单的交易想法或进行近似计算, 其准确性可通过其速度进行补偿。MetaTrader 5 中的标准测试器有一个有趣但很少使用的功能: 它可以在数学计算模式下执行计算。对于运行策略测试器来说这是一个有限的模式, 但它具有全面优化的所有优点: 可以使用云计算, 可以使用遗传优化器, 也可以收集自定义数据类型。

自定义策略测试器对于那些需要绝对速度的人来说也许是必需的, 但不限于此。在数学计算模式下进行测试也为研究人员开阔了通路。标准策略测试器允许尽可能地逼近真实模拟交易操作。这一需求在研究中并不总是有用的。例如, 有时需要估算交易系统的纯净效率, 而不必考虑滑点, 点差和佣金。本文开发的数学计算测试器提供了这种能力。

自然地, 任何事情都难以两全。本文也不例外。编写一个自定义策略测试器需要认真和耗时的工作。这里的目标微不足道: 展示配合合适的函数库, 创建一个自定义测试器并不像起初看来的那么困难。

如果我的同事们觉得这个话题很有意思, 那么在后续文章里将会看到所提想法的进展。


有关数学计算模式的一般信息

在策略测试器窗口中启动数学计算模式。为此, 请在下拉列表中选择相同名称的菜单项:

图例 1. 在策略测试器中选择数学计算模式

此模式仅调用一组有限的功能, 且交易环境 (品种, 账户信息, 交易服务器属性) 不可用。OnTester() 成为主要的调用函数, 用户可以使用它来设置特殊的 自定义优化标准。它将与其它标准优化条件一起使用, 并且可以显示在标准用户策略报告中。它在下面的屏幕截图中以红色勾勒:

 

图例 2. 自定义优化条件在 OnTester 函数中计算

由 OnTester 函数返回的值是可选的和可优化的。我们用一个简单的智能系统来展示这一点:

//+------------------------------------------------------------------+
//|                                                OnTesterCheck.mq5 |
//|                                   版权所有 2017, MetaQuotes 软件公司|
//|                                              http://www.mql5.com |
//+------------------------------------------------------------------+
#property copyright "版权所有 2017, MetaQuotes 软件公司"
#property link      "http://www.mql5.com"
#property version   "1.00"
input double x = 0.01;
//+------------------------------------------------------------------+
//| 测试器函数                                                         |
//+------------------------------------------------------------------+
double OnTester()
{
   double ret = MathSin(x);
   return(ret);
}
//+------------------------------------------------------------------+


它的代码只包含输入参数 x 和 OnTester 函数, 它根据传递的参数计算正弦值。在此例中, x。现在尝试优化此函数。为此, 请在策略测试器中选择 "慢速完整算法" 优化模式, 和之前的模拟模式: "数学计算"。

在优化参数中设置 x 的变化范围: 开始 — 0.01, 步长 — 0.01, 终止 — 10。一切准备就绪后, 运行策略测试器。它几乎会立即完成其工作。之后, 打开优化图形并在关联菜单中选择 "1D 图"。这将在图形表达中显示正弦函数:

图例 3. 正弦函数的图形表达

这种模式的一个显著特点是资源消耗最少。硬盘的读写操作最小化, 测试器代理无需下载所需品种的报价, 无需额外的计算, 所有计算都集中在 OnTester 函数中。

考虑到 OnTester 的高速度潜力, 可以创建一个能够执行简单模拟的自给自足计算模块。此为这个模块的元素:

  • 测试品种的历史
  • 虚拟仓位系统
  • 用于管理虚拟仓位的交易系统
  • 结果分析系统

模块的自给自足意即单一智能系统将包含测试所需的所有必要数据, 并使用测试系统本身。如果需要进行云优化, 该智能系统可以轻松传递到分布式计算网络。

我们继续介绍系统的第一部分, 即如何存储测试历史。


根据数学计算结果保存测试器的品种历史数据

数学计算模式并非意味着可以访问交易产品。调用函数如 CopyRates(Symbol(),...) 在这里没有意义。但是, 历史数据对于模拟是必要的。为此目的, 所需品种的报价历史记录可以存储在 uchar[] 类型的预压缩数组中:

uchar symbol[128394] = {0x98,0x32,0xa6,0xf7,0x64,0xbc...};

任何类型的数据 — 声音, 图像, 数字和字符串 — 可以表示为简单的字节集合。一个字节由 8 个二进制位组成。任何信息均以 "批" 的形式存储在由这些字节组成的序列中。MQL5 有一个特殊数据类型 — uchar, 每个值可以精确地表示一个字节。因此, 含有 100 个元素的 uchar 类型数组可以存储 100 个字节。

一个品种的行情由许多根柱线组成。每根柱线都包含有关柱线开盘时间, 价格 (最高, 最低, 开盘和收盘) 和成交量的信息。每个这样的值都存储在一个适当长度的变量中。这是表格:

数值 数据类型 字节长度
开盘时间 datetime 8
开盘价 double 8
最高价 double 8
最低价 double 8
收盘价 double 8
逐笔交易量 long  8
点差   int 4
真实交易量   long 8

很容易计算出, 存储一根柱线需要 60 个字节, 或者一个由 60 个元素组成的 uchar 数组。对于一个 24-小时开放的外汇市场, 一个交易日由 1,440 根分钟柱线组成。因此, 一年的一分钟历史由大约 391,680 根柱线组成。将这个数字乘以 60 个字节, 我们发现未压缩形式的一年历史记录约占 23 MB。这很多或是很少?以现代标准来衡量并不多, 但想象一下, 如果我们决定用 10 年的数据来测试智能系统会发生什么。这必须存储 230 MB 的数据, 甚至也许要通过网络分发它们。即使按现代标准来看也太多了。

因此, 有必要以某种方式压缩这些信息。幸运的是, 一个 已编写好的特殊函数库 可用于处理 Zip 存档。除了各种功能之外, 该函数库允许将压缩结果转换为字节数组, 这极大地方便了工作。

因此, 我们的算法将加载 MqlRates 数组, 将其转换为字节表示形式, 然后按照 Zip 存档对其进行压缩, 然后将压缩数据保存为 mqh 文件中定义的 uchar 数组。

若要将报价转换为字节数组, 转换系统通过union 类型进行。该系统允许将多种数据类型放置在一个存储域中。因此, 可以通过另一种寻址类型来访问一种类型的数据。这种联合将存储两种类型: MqlRates 结构和 uchar 数组, 其元素数量等于 MqlRates 的大小。若要了解该系统的工作原理, 请参阅 SaveRates.mq5 脚本的第一个版本, 该脚本将品种历史记录转换为 uchar 字节数组:

//+------------------------------------------------------------------+
//|                                                    SaveRates.mq5 |
//|                                    版权所有 2016, Vasiliy Sokolov。|
//|                                              http://www.mql5.com |
//+------------------------------------------------------------------+
#property copyright "版权所有 2016, Vasiliy Sokolov。"
#property link      "http://www.mql5.com"
#property version   "1.00"
#include <Zip\Zip.mqh>
#include <ResourceCreator.mqh>
input ENUM_TIMEFRAMES MainPeriod;

union URateToByte
{
   MqlRates bar;
   uchar    bar_array[sizeof(MqlRates)];
}RateToByte;
//+------------------------------------------------------------------+
//| 脚本程序开始函数                                                    |
//+------------------------------------------------------------------+
void OnStart()
{
   //-- 下载报价
   MqlRates rates[];
   int total = CopyRates(Symbol(), Period(), 0, 20000, rates);
   uchar symbol_array[];
   //-- 将它们转换为字节表示
   ArrayResize(symbol_array, sizeof(MqlRates)*total);
   for(int i = 0, dst = 0; i < total; i++, dst +=sizeof(MqlRates))
   {
      RateToByte.bar = rates[i];
      ArrayCopy(symbol_array, RateToByte.bar_array, dst, 0, WHOLE_ARRAY);
   }
   //-- 将它们压缩成一个 zip 存档
   CZip Zip;
   CZipFile* file = new CZipFile(Symbol(), symbol_array);
   Zip.AddFile(file);
   uchar zip_symbol[];
   //-- 获取压缩存档的字节表示
   Zip.ToCharArray(zip_symbol);
   //-- 把它写成一个 mqh 包含文件
   CCreator creator;
   creator.ByteArrayToMqhArray(zip_symbol, "rates.mqh", "rates");
}
//+------------------------------------------------------------------+

执行此代码后, zip_symbol 数组将包含一个压缩的 MqlRates 结构数组 — 压缩的报价历史记录。然后压缩数组作为 mqh 文件存储在计算机硬盘上。至于如何和为什么这样做的细节提供如下。

获取一笔报价的字节表示并将其压缩还不够。有必要将此表示形式记录为 uchar 数组。在这种情况下, 数组应该以资源的形式加载, 即它必须与程序一起编译。出于此目的, 创建一个包含此数组的特殊 mqh 头文件作为一组简单的 ASCII 字符。为此, 请使用特殊的 CResourceCreator 类:

//+------------------------------------------------------------------+
//|                                              ResourceCreator.mqh |
//|                                    版权所有 2017, Vasiliy Sokolov。|
//|                                              http://www.mql5.com |
//+------------------------------------------------------------------+
#property copyright "版权所有 2017, Vasiliy Sokolov。"
#property link      "http://www.mql5.com"
#include <Arrays\ArrayObj.mqh>
//+------------------------------------------------------------------+
//| 包含所创建的资源数组的字符串标识符                                     |
//+------------------------------------------------------------------+
class CResInfo : public CObject
{
public:
   string FileName;
   string MqhFileName;
   string ArrayName;
};
//+------------------------------------------------------------------+
//| 创建一个 MQL 资源作为字节数组。                                       |
//+------------------------------------------------------------------+
class CCreator
{
private:
   int      m_common;
   bool     m_ch[256];
   string   ToMqhName(string name);
   void     CreateInclude(CArrayObj* list_info, string file_name);
public:
            CCreator(void);
   void     SetCommonDirectory(bool common);
   bool     FileToByteArray(string file_name, uchar& byte_array[]);
   bool     ByteArrayToMqhArray(uchar& byte_array[], string file_name, string array_name);
   void     DirectoryToMqhArray(string src_dir, string dst_dir, bool create_include = false);
};
//+------------------------------------------------------------------+
//| 默认构造器                                                         |
//+------------------------------------------------------------------+
CCreator::CCreator(void) : m_common(FILE_COMMON)
{
   ArrayInitialize(m_ch, false);
   for(uchar i = '0'; i < '9'; i++)
      m_ch[i] = true;
   for(uchar i = 'A'; i < 'Z'; i++)
      m_ch[i] = true;
}
//+------------------------------------------------------------------+
//| 设置 FILE_COMMON 标志, 或删除它                                     |
//+------------------------------------------------------------------+
CCreator::SetCommonDirectory(bool common)
{
   m_common = common ? FILE_COMMON : 0;   
}

//+------------------------------------------------------------------+
//| 将 src_dir 目录中包含字节表示的所有文件                                |
//| 转换为 mqh 文件                                                    |
//+------------------------------------------------------------------+
void CCreator::DirectoryToMqhArray(string src_dir,string dst_dir, bool create_include = false)
{
   string file_name;
   string file_mqh;
   CArrayObj list_info;
   long h = FileFindFirst(src_dir+"\\*", file_name, m_common);
   if(h == INVALID_HANDLE)
   {
      printf("目录" + src_dir + " 未发现, 或它未包含文件");
      return;
   }
   do
   {
      uchar array[];
      if(FileToByteArray(src_dir+file_name, array))
      {
         string norm_name = ToMqhName(file_name);
         file_mqh = dst_dir + norm_name + ".mqh";
         ByteArrayToMqhArray(array, file_mqh, "m_"+norm_name);
         printf("创建资源: " + file_mqh);
         // 添加有关创建资源的信息
         CResInfo* info = new CResInfo();
         list_info.Add(info);
         info.FileName = file_name;
         info.MqhFileName = norm_name + ".mqh";
         info.ArrayName = "m_"+norm_name;
      }
   }while(FileFindNext(h, file_name));
   if(create_include)
      CreateInclude(&list_info, dst_dir+"include.mqh");
}
//+------------------------------------------------------------------+
/| 创建一个包含所有生成文件的 mqh 文件                                     |
//+------------------------------------------------------------------+
void CCreator::CreateInclude(CArrayObj *list_info, string file_name)
{
   int handle = FileOpen(file_name, FILE_WRITE|FILE_TXT|m_common);
   if(handle == INVALID_HANDLE)
   {
      printf("创建包含文件失败 " + file_name);
      return;
   }
   // 创建包含头文件
   for(int i = 0; i < list_info.Total(); i++)
   {
      CResInfo* info = list_info.At(i);
      string line = "#include \"" + info.MqhFileName + "\"\n";
      FileWriteString(handle, line);
   }
   // 创建一个函数, 将资源数组复制到调用代码
   FileWriteString(handle, "\n");
   FileWriteString(handle, "void CopyResource(string file_name, uchar &array[])\n");
   FileWriteString(handle, "{\n");
   for(int i = 0; i < list_info.Total(); i++)
   {
      CResInfo* info = list_info.At(i);
      if(i == 0)
         FileWriteString(handle, "   if(file_name == \"" + info.FileName + "\")\n");
      else
         FileWriteString(handle, "   else if(file_name == \"" + info.FileName + "\")\n");
      FileWriteString(handle,    "      ArrayCopy(array, " + info.ArrayName + ");\n");
   }
   FileWriteString(handle, "}\n");
   FileClose(handle);
}
//+------------------------------------------------------------------+
//| 将传递的名称转换为 MQL 变量的正确名称                                  |
//+------------------------------------------------------------------+
string CCreator::ToMqhName(string name)
{
   uchar in_array[];
   uchar out_array[];
   int total = StringToCharArray(name, in_array);
   ArrayResize(out_array, total);
   int t = 0;
   for(int i = 0; i < total; i++)
   {
      uchar ch = in_array[i];
      if(m_ch[ch])
         out_array[t++] = ch;
      else if(ch == ' ')
         out_array[t++] = '_';
      uchar d = out_array[t-1];
      int dbg = 4;
   }
   string line = CharArrayToString(out_array, 0, t);
   return line;
}
//+------------------------------------------------------------------+
//| 返回所传递文件的字节表达作为                                          |
//| byte_array 数组                                                   |
//+------------------------------------------------------------------+
bool CCreator::FileToByteArray(string file_name, uchar& byte_array[])
{
   int handle = FileOpen(file_name, FILE_READ|FILE_BIN|m_common);
   if(handle == -1)
   {
      printf("打开文件失败 " + file_name + ". Reason: " + (string)GetLastError());
      return false;
   }
   FileReadArray(handle, byte_array, WHOLE_ARRAY);
   FileClose(handle);
   return true;
}
//+------------------------------------------------------------------+
//| 将所传递的 byte_array 字节数组转换为 mqh 文件                          |
//| 其名为 file_name, 包含数组描述                                       |
//| array_name                                                       |
//+------------------------------------------------------------------+
bool CCreator::ByteArrayToMqhArray(uchar& byte_array[], string file_name, string array_name)
{
   int size = ArraySize(byte_array);
   if(size == 0)
      return false;
   int handle = FileOpen(file_name, FILE_WRITE|FILE_TXT|m_common, "");
   if(handle == -1)
      return false;
   string strSize = (string)size;
   string strArray = "uchar " +array_name + "[" + strSize + "] = \n{\n";
   FileWriteString(handle, strArray);
   string line = "   ";
   int chaptersLine = 32;
   for(int i = 0; i < size; i++)
   {
      ushort ch = byte_array[i];
      line += (string)ch;
      if(i == size - 1)
         line += "\n";
      if(i>0 && i%chaptersLine == 0)
      {
         if(i < size-1)
            line += ",\n";
         FileWriteString(handle, line);
         line = "   ";
      }
      else if(i < size - 1)
         line += ",";
   }
   if(line != "")
      FileWriteString(handle, line);
   FileWriteString(handle, "};");
   FileClose(handle);
   return true;
}

我们无需讨论它的操作细节, 只是笼统地描述它, 列出它的功能。

  • 读取硬盘驱动器上的任意文件, 并将其字节表示作为 uchar 数组存储在 mqh 文件中。
  • 读取硬盘任意目录, 并存储在此目录中将所有文件的字节表示。每个文件的字节表示位于单独的 mqh 文件中, 包含一个 uchar 数组。
  • 它将 uchar 字节数组作为输入, 并将其作为一个字符数组存储在单独的 mqh 文件中。
  • 创建一个特殊的头文件, 其中包含指向生成过程中创建的所有 mqh 文件的链接。另外, 还创建了一个特殊函数, 它将数组名称作为输入并返回其字节表示。该算法使用动态代码生成。 

所描述的类是 MQL 程序中常规资源分配系统的强大替代方案。

默认时, 所有文件操作取自共享文件目录 (FILE_COMMON)。如果您运行上一个清单中的脚本, 该文件夹将包含一个新的 rates.mqh 文件 (文件名由 ByteArrayToMqhArray 方法的第二个参数定义)。它将包含海量的 rates[ ]数组 (数组名称由此方法的第三个参数定义)。这是文件内容的一部分:


图例 4. MqlRates 报价生成一个名为 rates 的压缩字节数组

数据压缩工作良好。一年未压缩的 EURUSD 货币对一分钟历史记录大约需要 20 MB, 压缩后 — 只有 5 MB。但是, 最好不要在 MetaEditor 中打开 rates.mqh 文件: 它的大小比这个数字大得多, 编辑器可能会死锁。不过别担心。编译后, 文本被转换为字节, 程序的实际大小仅增加存储信息的实际值, 在这种情况下, 增加 5 MB。

顺便说一句, 这种技术可以在 ex5 程序中用来存储任何类型的必要信息, 而不仅仅是报价历史。


从压缩字节数组中加载 MqlRates

现在历史已经存储了, 只要在其开头添加 include 指令, 它就可以包含在任何 MQL 程序中:

...
#include "rates.mqh"
...

请注意 rates.mqh 文件应该移至程序本身的源代码目录中。

包含数据还不够。还必须编写一个过程模块将数据反转成正常的 MqlRates 数组。我们实现一个特殊的函数 LoadRates 来完成这一点。它将引用一个空的 MqlRates 数组作为输入。一旦执行, 数组将包含从压缩数组载入的传统 MqlRates 报价。这是该函数的代码:

//+------------------------------------------------------------------+
//|                                                      Mtester.mqh |
//|                                   版权所有 2017, MetaQuotes 软件公司|
//|                                              http://www.mql5.com |
//+------------------------------------------------------------------+
#property copyright "版权所有 2017, MetaQuotes 软件公司"
#property link      "http://www.mql5.com"
#include <Zip\Zip.mqh>
#include "rates.mqh"
//+------------------------------------------------------------------+
//| 将 MqlRates 投射到 uchar[]                                        |
//+------------------------------------------------------------------+
union URateToByte
{
   MqlRates bar;
   uchar    bar_array[sizeof(MqlRates)];
};
//+------------------------------------------------------------------+
//| 转换压缩数据至一个 MqlRates 报价数组                                  |
//| 返回接收的柱线数量, 如若失败                                          |
//| 返回 -1                                                           |
//+------------------------------------------------------------------+
int LoadRates(string symbol_name, MqlRates &mql_rates[])
{
   CZip Zip;
   Zip.CreateFromCharArray(rates);
   CZipFile* file = dynamic_cast<CZipFile*>(Zip.ElementByName(symbol_name));
   if(file == NULL)
      return -1;
   uchar array_rates[];
   file.GetUnpackFile(array_rates);
   URateToByte RateToBar;
   ArrayResize(mql_rates, ArraySize(array_rates)/sizeof(MqlRates));
   for(int start = 0, i = 0; start < ArraySize(array_rates); start += sizeof(MqlRates), i++)
   {
      ArrayCopy(RateToBar.bar_array, array_rates, 0, start, sizeof(MqlRates));
      mql_rates[i] = RateToBar.bar;
   }
   return ArraySize(mql_rates);
}
//+------------------------------------------------------------------+


该函数位于 Mtester.mqh 文件中。这是在数学计算模式下工作的第一个函数。新功能最终将被添加到 Mtester.mqh 文件中, 并且可能会演变成一个全方位的数学策略测试引擎。

我们为数学计算模式编写一个轻量策略。它只会执行两个功函数: 在 OnInit 函数中加载报价, 并在 OnTester 函数中计算所有收盘价的平均值。计算结果将返回到 MetaTrader:

//+------------------------------------------------------------------+
//|                                                      MExpert.mq5 |
//|                                   版权所有 2017, MetaQuotes 软件公司|
//|                                              http://www.mql5.com |
//+------------------------------------------------------------------+
#property copyright "版权所有 2017, MetaQuotes 软件公司"
#property link      "http://www.mql5.com"
#property version   "1.00"
#include "Mtester.mqh"
//+------------------------------------------------------------------+
//| 测试中用到的报价                                                    |
//+------------------------------------------------------------------+
MqlRates Rates[];
//+------------------------------------------------------------------+
//| 智能系统初始化函数                                                  |
//+------------------------------------------------------------------+
int OnInit()
{
   //-- 加载指定品种的报价。
   if(LoadRates(Symbol(), Rates)==-1)
   {
      printf("报价品种 " + Symbol() + " 未发现。创建相应的报价资源。");
      return INIT_PARAMETERS_INCORRECT;
   }
   printf("已加载 " + (string)ArraySize(Rates) + " 根柱线, 品种为 " + Symbol());
   return(INIT_SUCCEEDED);
}

//+------------------------------------------------------------------+
//| 测试器函数                                                         |
//+------------------------------------------------------------------+
double OnTester()
{
   double mean = 0.0;
   for(int i = 0; i < ArraySize(Rates); i++)
      mean += Rates[i].close;
   mean /= ArraySize(Rates);
   return mean;
}
//+------------------------------------------------------------------+

智能系统编译后, 将其加载到策略测试器中并选择 "数学计算" 模式。运行测试并转至流水帐:

2017.12.13 15:12:25.127 Core 2  math calculations test of Experts\MTester\MExpert.ex5 started
2017.12.13 15:12:25.127 Core 2  已加载 354159 根柱线, 品种为 EURUSD
2017.12.13 15:12:25.127 Core 2  OnTester result 1.126596405653942
2017.12.13 15:12:25.127 Core 2  EURUSD,M15: mathematical test passed in 0:00:00.733 (total tester working time 0:00:01.342)
2017.12.13 15:12:25.127 Core 2  217 Mb memory used

如您所见, EA 按预期工作。所有报价均正确加载, 正如已加载柱线数量的记录所示。此外, 它正确地遍历所有柱线并计算平均值, 其结果返回到调用线程。去年所有 EURUSD 报价的平均价格等于 1.12660。


基于移动均线的原型策略

取得的成果令人印象深刻: 接收数据并压缩, 然后作为静态 uchar 数组存储, 再后将数据解压缩并转换回报价数组。现在是时候编写第一个有用策略。我们选用基于两条移动均线交叉点的经典版本。这个策略很容易实现。由于数学计算模式下未提供交易环境, 因此不能直接调用像 iMA 这样的指标。代之, 移动平均值必须手工计算。这种测试模式的主要任务是最大加速度。因此, 所用算法必须工作得很快。已知移动平均值的计算是指计算复杂度为 O(1) 的简单问题类。这意味着平均值的计算速度不应取决于移动平均周期。为此目的, 将使用环形缓冲区的预制函数库。该算法的细节已经在 单独的文章 中讨论过。

首先, 创建第一个智能系统的模板:

//+------------------------------------------------------------------+
//|                                                      MExpert.mq5 |
//|                                   版权所有 2017, MetaQuotes 软件公司|
//|                                              http://www.mql5.com |
//+------------------------------------------------------------------+
#property copyright "版权所有 2017, MetaQuotes 软件公司"
#property link      "http://www.mql5.com"
#property version   "1.00"
#include "Mtester.mqh"
#include <RingBuffer\RiSma.mqh>

input int PeriodFastMa = 9;
input int PeriodSlowMa = 16;

CRiSMA FastMA;    // 计算快速移动平均值的环形缓冲区
CRiSMA SlowMA;    // 计算慢速移动平均值的环形缓冲区
//+------------------------------------------------------------------+
//| 测试中用到的报价                                                    |
//+------------------------------------------------------------------+
MqlRates Rates[];
//+------------------------------------------------------------------+
//| 智能系统初始化函数                                                  |
//+------------------------------------------------------------------+
int OnInit()
{
   //-- 验证参数组合的正确性
   //-- 快速移动平均值不能小于慢速移动平均值
   if(PeriodFastMa >= PeriodSlowMa)
      return INIT_PARAMETERS_INCORRECT;
   //-- 初始化环形缓冲区的周期
   FastMA.SetMaxTotal(PeriodFastMa);
   SlowMA.SetMaxTotal(PeriodSlowMa);
   //-- 加载指定品种的报价。
   if(LoadRates(Symbol(), Rates)==-1)
   {
      printf("报价品种 " + Symbol() + " 未发现。创建相应的报价资源。");
      return INIT_FAILED;
   }
   printf("已加载 " + (string)ArraySize(Rates) + " 根柱线, 品种为 " + Symbol());
   return(INIT_SUCCEEDED);
}

//+------------------------------------------------------------------+
//| 策略描述                                                          |
//+------------------------------------------------------------------+
double OnTester()
{
   for(int i = 1; i < ArraySize(Rates); i++)
   {
      FastMA.AddValue(Rates[i].close);
      SlowMA.AddValue(Rates[i].close);
      // EA 逻辑将位于此处
   }
   return 0.0;
}
//+------------------------------------------------------------------+


它定义了两个参数, 快速和慢速均线的均化周期。然后声明两个环形缓冲区来计算这些平均值。初始化模块验证输入参数的正确性。由于参数不由用户设置, 而是由策略测试程序在优化模式下自动选择, 参数往往不能正确组合。在这种情况下, 快速均线可能会比慢速均线小。为了避免这种困惑并节省优化时间, 此次测试在开始之初即会结束。这是由 OnInit 模块返回 INIT_PARAMETERS_INCORRECT 常量来完成的。

一旦缓冲区被初始化, 参数已检查且已加载报价, 就会开始运行测试本身: 启动 OnTester 函数。其中, 主要测试将在 'for' 模块内。该代码表明, 如果 FastMA 环形缓冲区的平均值大于 SlowMA 的平均值, 则需要开多头仓位, 反之亦然。不过, 此刻仍然没有开多头或空头仓位的交易模块。它还尚未编写。 


虚拟仓位类

如前所述, 数学计算模式不适合计算任何策略。因此, 它没有交易功能。另外, 也不能使用 MetaTrader 环境。术语 "仓位" 一词完全没有意义, 它根本不存在。因此, 有必要创建一个简化的 MetaTrader 仓位模拟。它将只包含最必要的信息。为此, 创建一个含有这些字段的类: 

  • 开仓时间;
  • 开仓价格;
  • 平仓时间;
  • 平仓价格;
  • 仓位交易量;
  • 开仓时的点差;
  • 仓位方向。

也许, 将来会补充更多的信息, 但现在这些字段已经足够了。

//+------------------------------------------------------------------+
//|                                                      Mtester.mqh |
//|                                   版权所有 2017, MetaQuotes 软件公司|
//|                                              http://www.mql5.com |
//+------------------------------------------------------------------+
#property copyright "版权所有 2017, MetaQuotes 软件公司"
#property link      "http://www.mql5.com"
#include <Object.mqh>
#include "rates.mqh"
#include "Type2Char.mqh"
//+------------------------------------------------------------------+
//| 虚拟仓位类, 用于数学计算模式的测试器                                    |
//+------------------------------------------------------------------+
class CMposition : public CObject
{
private:
   datetime    m_time_open;
   datetime    m_time_close;
   double      m_price_open;
   double      m_price_close;
   double      m_volume;
   int         m_spread;
   ENUM_POSITION_TYPE m_type;
public:
               CMposition(void);
   static int  Sizeof(void);
   bool        IsActive(void);
   datetime    TimeOpen(void);
   datetime    TimeClose(void);
   double      PriceOpen(void);
   double      PriceClose(void);
   double      Volume(void);
   double      Profit(void);
   ENUM_POSITION_TYPE PositionType(void);
   static CMposition*  CreateOnBarOpen(MqlRates& bar, ENUM_POSITION_TYPE pos_type, double vol);
   void        CloseOnBarOpen(MqlRates& bar);
};
//+------------------------------------------------------------------+
//| 一个 CMposition 仓位占用 45 个字节的数据                              |
//+------------------------------------------------------------------+
int CMposition::Sizeof(void)
{
   return 48;
}
CMposition::CMposition(void):m_time_open(0),
                             m_time_close(0),
                             m_price_open(0.0),
                             m_price_close(0.0),
                             m_volume(0.0)
{
}
//+------------------------------------------------------------------+
//| True, 如果已平仓                                                   |
//+------------------------------------------------------------------+
bool CMposition::IsActive()
{
   return m_time_close == 0;
}
//+------------------------------------------------------------------+
//| 开仓时间                                                           |
//+------------------------------------------------------------------+
datetime CMposition::TimeOpen(void)
{
   return m_time_open;
}
//+------------------------------------------------------------------+
//| 平仓时间                                                           |
//+------------------------------------------------------------------+
datetime CMposition::TimeClose(void)
{
   return m_time_close;
}
//+------------------------------------------------------------------+
//| 开仓价格                                                           |
//+------------------------------------------------------------------+
double CMposition::PriceOpen(void)
{
   return m_price_open;
}
//+------------------------------------------------------------------+
//| 平仓价格                                                           |
//+------------------------------------------------------------------+
double CMposition::PriceClose(void)
{
   return m_price_close;
}
//+------------------------------------------------------------------+
//| 仓位交易量                                                         |
//+------------------------------------------------------------------+
double CMposition::Volume(void)
{
   return m_volume;
}
//+------------------------------------------------------------------+
//| 返回交易仓位的类型                                                  |
//+------------------------------------------------------------------+
ENUM_POSITION_TYPE CMposition::PositionType(void)
{
   return m_type;
}
//+------------------------------------------------------------------+
//| 仓位盈利                                                          |
//+------------------------------------------------------------------+
double CMposition::Profit(void)
{
   if(IsActive())
      return 0.0;
   int sign = m_type == POSITION_TYPE_BUY ? 1 : -1;
   double pips = (m_price_close - m_price_open)*sign;
   double profit = pips*m_volume;
   return profit;
}
//+------------------------------------------------------------------+
//| 根据传递的参数创建仓位                                               |
//+------------------------------------------------------------------+
static CMposition* CMposition::CreateOnBarOpen(MqlRates &bar, ENUM_POSITION_TYPE pos_type, double volume)
{
   CMposition* position = new CMposition();
   position.m_time_open = bar.time;
   position.m_price_open = bar.open;
   position.m_volume = volume;
   position.m_type = pos_type;
   return position;
}
//+------------------------------------------------------------------+
//| 以所传递柱线的开盘价平仓                                              |
//+------------------------------------------------------------------+
void CMposition::CloseOnBarOpen(MqlRates &bar)
{
   m_price_close = bar.open;
   m_time_close = bar.time;
}
//+------------------------------------------------------------------+


创建一个仓位是此实现中最有趣的一点。其字段受到 external 修饰符保护, 但静态 CreateOnBarOpen 方法返回具有正确参数集的类对象。若不引用此方法, 就无法创建该类的对象。这可以防止数据免受无意中的更改。


交易模块类

现在有必要创建一个类来管理这些仓位。这将是 MetaTrade 函数的模拟。显然, 仓位本身也应该存储在这个模块当中。两个 CArrayObj 集合旨在实现此目的: 第一个 — 活跃 — 用于存储策略的活跃仓位, 另一个 — 历史 — 将包含历史中的仓位。

该类还将有特殊的开仓和平仓的方法:

  • EntryAtOpenBar — 按照所需方向和交易量开仓;
  • CloseAtOpenBar — 在指定索引上平仓。

这些仓位将按照所传递柱线的价格开仓和平仓。不幸地是, 这种方法并不能防止 "偷窥未来", 但它很容易实现, 而且速度非常快。

CMtrade 类 (让我们这样命名) 结果非常简单:

//+------------------------------------------------------------------+
//|                                                      Mtester.mqh |
//|                                   版权所有 2017, MetaQuotes 软件公司|
//|                                              http://www.mql5.com |
//+------------------------------------------------------------------+
#property copyright "版权所有 2017, MetaQuotes 软件公司"
#property link      "http://www.mql5.com"
#include <Object.mqh>
#include <Arrays\ArrayObj.mqh>
#include "Mposition.mqh"
//+------------------------------------------------------------------+
//| 用于打开虚拟仓位的交易模块                                            |
//+------------------------------------------------------------------+
class CMtrade
{
public:
               CMtrade(void);
               ~CMtrade();
   CArrayObj   Active;
   CArrayObj   History;
   void        EntryAtOpenBar(MqlRates &bar, ENUM_POSITION_TYPE pos_type, double volume);
   void        CloseAtOpenBar(MqlRates &bar, int pos_index);
};
//+------------------------------------------------------------------+
//| 默认构造器                                                         |
//+------------------------------------------------------------------+
CMtrade::CMtrade(void)
{
   Active.FreeMode(false);
}
//+------------------------------------------------------------------+
//| 删除所有剩余仓位                                                    |
//+------------------------------------------------------------------+
CMtrade::~CMtrade()
{
   Active.FreeMode(true);
   Active.Clear();
}
//+------------------------------------------------------------------+
//| 创建新仓位, 并将其加入                                               |
//| 活跃仓位清单。                                                      |
//+------------------------------------------------------------------+
void CMtrade::EntryAtOpenBar(MqlRates &bar, ENUM_POSITION_TYPE pos_type, double volume)
{
   CMposition* pos = CMposition::CreateOnBarOpen(bar, pos_type, volume);
   Active.Add(pos);
}
//+------------------------------------------------------------------+
//| 按照 pos_index 索引指定柱线的开盘价                                   |
//| 将所有仓位平仓                                                      |
//+------------------------------------------------------------------+
void CMtrade::CloseAtOpenBar(MqlRates &bar, int pos_index)
{
   CMposition* pos = Active.At(pos_index);
   pos.CloseOnBarOpen(bar);
   Active.Delete(pos_index);
   History.Add(pos);
}
//+------------------------------------------------------------------+


 实际上, 它的所有功能都被缩减为两个函数:

  1. 从静态 CMposition::CreateOnBarOpen 方法中获取新仓位, 并将其添加到活跃清单 (EntryOnOpenBar 方法);
  2. 将所选仓位从活跃仓位清单里移到历史仓位清单, 移动的仓位由静态 CMposition::CLoseOnBarOpen 方法平仓。

交易类已创建, 智能系统的所有组件现在都可用于测试。


智能系统的第一次测试。在优化器中工作

我们把所有的组件放在一起。此为基于两条移动平均线策略的源代码, 用于在数学优化器中工作。 

//+------------------------------------------------------------------+
//|                                                      MExpert.mq5 |
//|                                   版权所有 2017, MetaQuotes 软件公司|
//|                                              http://www.mql5.com |
//+------------------------------------------------------------------+
#property copyright "版权所有 2017, MetaQuotes 软件公司"
#property link      "http://www.mql5.com"
#property version   "1.00"
#include <RingBuffer\RiSma.mqh>
#include "Mtester.mqh"

input int PeriodFastMa = 9;
input int PeriodSlowMa = 16;

CRiSMA FastMA;    // 计算快速移动平均值的环形缓冲区
CRiSMA SlowMA;    // 计算慢速移动平均值的环形缓冲区
CMtrade Trade;    // 用于虚拟计算的交易模块

//+------------------------------------------------------------------+
//| 测试中用到的报价                                                    |
//+------------------------------------------------------------------+
MqlRates Rates[];
//+------------------------------------------------------------------+
//| 智能系统初始化函数                                                  |
//+------------------------------------------------------------------+
int OnInit()
{
   //-- 验证参数组合的正确性
   //-- 快速移动平均值不能小于慢速移动平均值
   //if(PeriodFastMa >= PeriodSlowMa)
   //   return INIT_PARAMETERS_INCORRECT;
   //-- 初始化环形缓冲区的周期
   FastMA.SetMaxTotal(PeriodFastMa);
   SlowMA.SetMaxTotal(PeriodSlowMa);
   //-- 加载指定品种的报价。
   if(LoadRates(Symbol(), Rates)==-1)
   {
      printf("报价品种 " + Symbol() + " 未发现。创建相应的报价资源。");
      return INIT_FAILED;
   }
   printf("已加载 " + (string)ArraySize(Rates) + " 根柱线, 品种为 " + Symbol());
   return(INIT_SUCCEEDED);
}
//+------------------------------------------------------------------+
//| 策略描述                                                           |
//+------------------------------------------------------------------+
double OnTester()
{
   for(int i = 1; i < ArraySize(Rates)-1; i++)
   {
      MqlRates bar = Rates[i];
      FastMA.AddValue(Rates[i].close);
      SlowMA.AddValue(Rates[i].close);
      ENUM_POSITION_TYPE pos_type = FastMA.SMA() > SlowMA.SMA() ? POSITION_TYPE_BUY : POSITION_TYPE_SELL;
      //-- 与当前信号相反的所有仓位平仓
      for(int k = Trade.Active.Total()-1; k >= 0 ; k--)
      {
         CMposition* pos = Trade.Active.At(k);
         if(pos.PositionType() != pos_type)
            Trade.CloseAtOpenBar(Rates[i+1], k);   
      }
      //-- 如果没有仓位, 则在指定方向上开新仓。
      if(Trade.Active.Total() == 0)
         Trade.EntryAtOpenBar(Rates[i+1], pos_type, 1.0);
   }
   double profit = 0.0;
   for(int i = 0; i < Trade.History.Total(); i++)
   {
      CMposition* pos = Trade.History.At(i);
      profit += pos.Profit();
   }
   return profit;
}
//+------------------------------------------------------------------+


OnTester 函数现已完成。代码非常简单。我们一步步研究它的操作。

  1. 一个 'for' 循环遍历所有报价。
  2. 在循环内判断当前的成交方向: 如果快速 SMA 高于慢速 SMA, 则买入, 否则卖出。
  3. 所有活跃的成交都会遍历, 如果它们的方向与当前方向不匹配, 则它们将被平仓。
  4. 如果没有仓位, 则在指定的方向上开新仓。
  5. 在搜索结束时, 再次遍历所有已平仓位并计算其总利润, 然后返回给策略测试器。

智能系统现已准备好在优化器中进行测试。只需在数学计算模式下运行它。为确保优化工作, 我们对移动均线参数进行全面搜索, 如下图所示: 

图例 5. 参数优化选择字段

提供的示例显示了 1000 次优化通关. 每次优化通关处理历时 1 年的一分钟历史数据。尽管如此, 在这种模式下计算并不会占用太多时间。在装有 i7 处理器的计算机上, 整个优化过程大约需要 1 分钟, 之后创建一张图表:

图例 6. 在 "慢速完整算法" 模式下 1000 次通关的图形。

但截至目前, 用来分析所获结果的工具非常稀少。事实上, 我们目前所拥有的只有一个反映虚拟利润的单一数字。为了解决这一情况, 有必要开发一种自定义优化数据格式, 并提出一种生成和加载它的机制。我们将在下面详细讨论。

使用分帧机制保存自定义优化结果

MetaTrader 5 实现了一种非常先进的处理自定义数据的技术。它基于所谓的 分帧 的生成和复原机制。这些本质上是普通的二进制数据, 即可以作为单独的值亦或作为这些值的数组放置。例如, 在优化过程中, 可以生成任意大小的数据数组并将其发送到 MetaTrader 5 策略测试器。包含在该数组中的数据可以使用 FrameNext 函数读取并进一步处理, 例如, 显示在屏幕上。使用分帧操作只能在优化模式下进行, 并且只能在三个函数中使用: OnTesterInit(), OnTesterDeinit() 和 OnTesterPass()。它们都没有参数, 也没有返回值。但一切都比看起来容易。为了说明这一点, 我们创建一个简单的脚本来展示分帧操作的算法:

//+------------------------------------------------------------------+
//|                                               OnTesterSample.mq5 |
//|                                   版权所有 2017, MetaQuotes 软件公司|
//|                                              http://www.mql5.com |
//+------------------------------------------------------------------+
#property copyright "版权所有 2017, MetaQuotes 软件公司"
#property link      "http://www.mql5.com"
#property version   "1.00"
input int Param = 1;
//+------------------------------------------------------------------+
//| OnTesterInit 函数                                                 |
//+------------------------------------------------------------------+
void OnTesterInit()
{
   printf("优化开始");      
}
//+------------------------------------------------------------------+
//| 策略通关启于此处                                                    |
//+------------------------------------------------------------------+
double OnTester()
{
   uchar data[5] = {1,2,3,4,5};        // 生成分帧数据
   FrameAdd("sample", 1, Param, data); // 用提供的数据创建一个新分帧
   return 3.0;
}
//+------------------------------------------------------------------+
//| 最后添加的优化分帧可以在这里获得                                       |
//+------------------------------------------------------------------+
void OnTesterPass()
{
   ulong pass = 0;
   string name = "";
   ulong id = 0;
   double value = 0.0;
   uchar data[];
   FrameNext(pass, name, id, value, data);
   //-- 创建一个通关文件并将其添加到 zip 压缩文件中
   printf("收到新分帧 # " + (string)pass + ". 名称: " + (string)name + " ID: " + (string)id + " 参数值: " + DoubleToString(value, 0));
}
//+------------------------------------------------------------------+
//| OnTesterDeinit 函数                                               |
//+------------------------------------------------------------------+
void OnTesterDeinit()
{
   printf("优化完成");
}
//+------------------------------------------------------------------+

在策略测试器中选择数学计算模式并运行此代码。设置 "慢速完整算法" 作为优化模式。唯一的参数 Param 将在 10 到 90 之间变化, 步长为 10。

有关接收新分帧的消息将在优化开始后立即开始显示。优化的开始和结束也通过特殊事件进行跟踪。应用程序日志:

2017.12.19 16:58:08.101 OnTesterSample (EURUSD,M15)     优化开始
2017.12.19 16:58:08.389 OnTesterSample (EURUSD,M15)     收到新分帧 # 1. 名称: sample ID: 1 Param 值: 20
2017.12.19 16:58:08.396 OnTesterSample (EURUSD,M15)     收到新分帧 # 0. 名称: sample ID: 1 Param 值: 10
2017.12.19 16:58:08.408 OnTesterSample (EURUSD,M15)     收到新分帧 # 4. 名称: sample ID: 1 Param 值: 50
2017.12.19 16:58:08.426 OnTesterSample (EURUSD,M15)     收到新分帧 # 5. 名称: sample ID: 1 Param 值: 60
2017.12.19 16:58:08.426 OnTesterSample (EURUSD,M15)     收到新分帧 # 2. 名称: sample ID: 1 Param 值: 30
2017.12.19 16:58:08.432 OnTesterSample (EURUSD,M15)     收到新分帧 # 3. 名称: sample ID: 1 Param 值: 40
2017.12.19 16:58:08.443 OnTesterSample (EURUSD,M15)     收到新分帧 # 6. 名称: sample ID: 1 Param 值: 70
2017.12.19 16:58:08.444 OnTesterSample (EURUSD,M15)     收到新分帧 # 7. 名称: sample ID: 1 Param 值: 80
2017.12.19 16:58:08.450 OnTesterSample (EURUSD,M15)     收到新分帧 # 8. 名称: sample ID: 1 Param 值: 90
2017.12.19 16:58:08.794 OnTesterSample (EURUSD,M15)     优化完成

最令人感兴趣的是显示分帧编号、其标识符和 Param 参数值信息的消息。所有这些有价值的信息都可以使用 FrameNext 函数进行还原。

这种模式的一个有趣功能是 智能系统的双重启动。一款带有事件处理程序的智能系统在其代码中会启动两次: 首先在策略优化器中, 然后在 实时 图表上。当优化器中的智能系统生成新数据时, 图表上运行的智能系统会收到它们。因此, 智能系统的源代码由不同实例处理, 即便它们位于相同的地方。

一旦在 OnTesterPass 函数中接收到数据, 就能够以任何方式处理它们。在测试样本中, 这些数据仅通过 printf 函数输出到控制台。但是要实现的数据处理可能变得更加复杂。这将在下一节中讨论。


获取仓位历史记录的字节表达。分帧保存数据

分帧机制提供了一种便捷的方式来保存、处理和分发信息。但是, 有必要自己生成这些信息。在示例数组中, 它是一个简单的具有值 1,2,3,4,5 的静态 uchar 数组。这些数据没有多大用处。但字节数组可以有任意长度并存储任何数据。为此, 应将自定义数据类型转换为 uchar 类型的字节数组。MqlRates 已经完成了类似的操作, 其中报价保存在字节数组中。自定义数据也一样。

自定义策略测试器由两部分组成。第一部分生成数据, 第二部分分析数据并以用户友好的形式显示它们。同样显而易见的是, 策略分析的主要信息可以通过分析历史上的所有交易来获得。因此, 在每次运行结束时, 历史中的所有交易都将转换为字节数组, 该数组稍后将添加到新的分帧中。在 OnTesterPass() 函数中接收到这样的分帧后, 可以将它添加到之前接收到的分帧中, 从而创建一个完整的分帧集合。 

不仅需要将有关仓位的数据转换为字节数组, 还需要提取其数据。对于每种数据类型, 这将需要 两个过程

  • 将自定义类型转换为字节数组的过程;
  • 将字节数组转换为自定义类型的过程。

我们已有的 CMtrade 交易模块含有两个仓位集合 — 存档和历史。我们只专注于历史仓位。将相应编写虚拟仓位转换过程的方法。

将仓位转换为字节数组的方法:

//+------------------------------------------------------------------+
//| 将仓位转换为字节表示形式的数组                                         |
//+------------------------------------------------------------------+
int CMposition::ToCharArray(int dst_start, uchar &array[])
{
   int offset = dst_start;
   //-- 复制开仓时间
   type2char.time_value = m_time_open;
   ArrayCopy(array, type2char.char_array, offset, 0, sizeof(datetime));
   offset += sizeof(datetime);
   //-- 复制平仓时间
   type2char.time_value = m_time_close;
   ArrayCopy(array, type2char.char_array, offset, 0, sizeof(datetime));
   offset += sizeof(datetime);
   //-- 复制开仓价格
   type2char.double_value = m_price_open;
   ArrayCopy(array, type2char.char_array, offset, 0, sizeof(double));
   offset += sizeof(double);
   //-- 复制平仓价格
   type2char.double_value = m_price_close;  
   ArrayCopy(array, type2char.char_array, offset, 0, sizeof(double));
   offset += sizeof(double);
   //-- 复制仓量
   type2char.double_value = m_volume; 
   ArrayCopy(array, type2char.char_array, offset, 0, sizeof(double));
   offset += sizeof(double);
   //-- 复制品种点差
   type2char.int_value = m_spread;
   ArrayCopy(array, type2char.char_array, offset, 0, sizeof(int));
   offset += sizeof(int);
   //-- 复制仓位类型
   type2char.int_value = m_type;
   ArrayCopy(array, type2char.char_array, offset, 0, sizeof(char));
   offset += sizeof(int);
   //-- 返回最后的偏移量
   return offset;
}

 逆过程:

//+------------------------------------------------------------------+
//| 从字节数组中加载仓位                                                 |
//+------------------------------------------------------------------+
int CMposition::FromCharArray(int dst_start, uchar &array[])
{
   int offset = dst_start;
   //-- 复制开仓时间
   ArrayCopy(type2char.char_array, array, 0, offset, sizeof(datetime));
   m_time_open = type2char.time_value;
   offset += sizeof(datetime);
   //-- 复制平仓时间
   ArrayCopy(type2char.char_array, array, 0, offset, sizeof(datetime));
   m_time_close = type2char.time_value;
   offset += sizeof(datetime);
   //-- 复制开仓价格
   ArrayCopy(type2char.char_array, array, 0, offset, sizeof(double));
   m_price_open = type2char.double_value;
   offset += sizeof(double);
   //-- 复制平仓价格
   ArrayCopy(type2char.char_array, array, 0, offset, sizeof(double));
   m_price_close = type2char.double_value;
   offset += sizeof(double);
   //-- 复制仓量
   ArrayCopy(type2char.char_array, array, 0, offset, sizeof(double));
   m_volume = type2char.double_value;
   offset += sizeof(double);
   //-- 复制品种点差
   ArrayCopy(type2char.char_array, array, 0, offset, sizeof(int));
   m_spread = type2char.int_value;
   offset += sizeof(int);
   //-- 复制仓位类型
   ArrayCopy(type2char.char_array, array, 0, offset, sizeof(int));
   m_type = (ENUM_POSITION_TYPE)type2char.int_value;
   offset += sizeof(int);
   //-- 返回最后的偏移量
   return offset;
}

TypeToChar 联合 (使用其实例 type2char) 是两个转换过程的核心:

//+------------------------------------------------------------------+
//| 转换简单类型至字节数组                                               |
//+------------------------------------------------------------------+
union TypeToChar
{
   uchar    char_array[128];
   int      int_value;
   double   double_value;
   float    float_value;
   long     long_value;
   short    short_value;
   bool     bool_value;
   datetime time_value;
   char     char_value;
};

所有内容都与报价转换部分讨论的 RateToByte 联合相似。 

所有过程的设计均遵从这种方式: 它们能够从包含所有已平仓数据的虚拟仓位全局数组中加载数据。这允许组织高效的穷举算法, 而不需要额外复制存储器。

CMTrade 类将遍历历史中的所有仓位。这是合乎逻辑的, 因为它里面存储了历史仓位集合。该类与 CMposition 类似, 在两个方向上工作: 将一组历史仓位转换为一个 uchar 数组, 并执行逆反的过程: 从字节数组中加载历史仓位清单。

将集合转换为字节数组的过程:

//+------------------------------------------------------------------+
//| 将历史仓位清单转换为压缩的 zip 存档                                    |
//| 并形成字节数组。若成功, 返回 true                                     |
//| 否则 false。                                                      |
//+------------------------------------------------------------------+
bool CMtrade::ToCharArray(uchar &array[])
{
   int total_size = CMposition::Sizeof()*History.Total();
   if(total_size == 0)
   {
      printf(__FUNCTION__ +  ": 接收的数组为空");
      return false;
   }
   if(ArraySize(array) != total_size && ArrayResize(array, total_size) != total_size)
   {
      printf(__FUNCTION__ +  ": 调整接收数组大小失败");
      return false;
   }
   //-- 将仓位存储在字节流中
   for(int offset = 0, i = 0; offset < total_size; i++)
   {
      CMposition* pos = History.At(i);
      offset = pos.ToCharArray(offset, array);
   }
   return true;
}

逆过程:

//+------------------------------------------------------------------+
//| 从压缩的 zip 存档中加载历史仓位清单                                   |
//| 并以字节数组形式传递。如果成功, 返回 true                              |
//| 否则 false。                                                      |
//+------------------------------------------------------------------+
bool CMtrade::FromCharArray(uchar &array[], bool erase_prev_pos = true)
{
   if(ArraySize(array) == 0)
   {
      printf(__FUNCTION__ +  ": 接收的数组为空");
      return false;
   }
   //-- 字节流的大小必须与仓位的字节表示完全匹配
   int pos_total = ArraySize(array)/CMposition::Sizeof();
   if(ArraySize(array)%CMposition::Sizeof() != 0)
   {
      printf(__FUNCTION__ +  ": 接收数组大小错误");
      return false;
   }
   if(erase_prev_pos)
      History.Clear();
   //-- 从字节流中还原所有仓位
   for(int offset = 0; offset < ArraySize(array);)
   {
      CMposition* pos = new CMposition();
      offset = pos.FromCharArray(offset, array);
      History.Add(pos);
   }
   return History.Total() > 0;
}

为了将所有元素放在一起, 只需在遍历结束处获取历史仓位的字节表示, 并将其保存在分帧中:

//+------------------------------------------------------------------+
//| 策略描述                                                          |
//+------------------------------------------------------------------+
double OnTester()
{
   for(int i = 1; i < ArraySize(Rates)-1; i++)
   {
      MqlRates bar = Rates[i];
      FastMA.AddValue(Rates[i].close);
      SlowMA.AddValue(Rates[i].close);
      ENUM_POSITION_TYPE pos_type = FastMA.SMA() > SlowMA.SMA() ? POSITION_TYPE_BUY : POSITION_TYPE_SELL;
      //-- 与当前信号相反的所有仓位平仓
      for(int k = Trade.Active.Total()-1; k >= 0 ; k--)
      {
         CMposition* pos = Trade.Active.At(k);
         if(pos.PositionType() != pos_type)
            Trade.CloseAtOpenBar(Rates[i+1], k);   
      }
      //-- 如果没有仓位, 则在指定方向上开新仓。
      if(Trade.Active.Total() == 0)
         Trade.EntryAtOpenBar(Rates[i+1], pos_type, 1.0);
   }
   uchar array[];
   //-- 获取历史仓位的字节表示
   Trade.ToCharArray(array); 
   //-- 将字节表示加载到分帧中并将其发送给进一步处理
   FrameAdd(MTESTER_STR, MTESTER_ID, 0.0, array);  
   return Trade.History.Total();
}

一旦分帧形成并发送到 OnTesterPass() 过程进行处理, 就必须解决下一步如何处理分帧。如前所述, 该策略测试器由两部分组成: 数据生成模块和收集的信息分析模块。这种分析需要将所有生成的分帧按照便利和经济的格式进行存储, 以便以后可以轻松分析此格式。这可以利用 zip 存档 完成。首先, 它有效地压缩数据, 这意味着即便有一千笔成交的信息也不会占用太多空间。其次, 它提供了一个便利的文件系统。每次通关可以作为单独的文件存储在一个 zip 存档中。

因此, 我们创建一个将分帧的字节内容转换为 zip 存档的过程。

//+------------------------------------------------------------------+
//| 将每次新通关添加到 zip 存档                                          |
//+------------------------------------------------------------------+
void OnTesterPass()
{
   ulong pass = 0;
   string name = "";
   ulong id = 0;
   double value = 0.0;
   uchar data[];
   FrameNext(pass, name, id, value, data);
   //-- 创建一个通关文件并将其添加到 zip 压缩文件中
   printf("接收新存档大小 " + (string)ArraySize(data));
   string file_name = name + "_" + (string)id + "_" + (string)pass + "_" + DoubleToString(value, 5)+".hps";
   CZipFile* zip_file = new CZipFile(file_name, data);
   Zip.AddFile(zip_file);
}

由于使用 zip 压缩存档的类非常强大, 且具有通用方法, 因此将新通关添加到存档中作为单独文件极端简单。本质上, OnTesterPass 将一个新的 zip 文件添加到全局范围声明的 Zip 存档:

CZip     Zip;     // Zip 存档由优化通关填充

此过程在每次优化通关结束时并行调用, 并且不占用大量资源。

在优化结束时, 生成的 zip 存档应该简单地保存为相应的 zip 文件。这也很简单。这是在 OnTesterDeinit() 过程中执行的:

//+------------------------------------------------------------------+
//| 将所有通关的 zip 存档保存到计算机硬盘                                  |
//+------------------------------------------------------------------+
void OnTesterDeinit()
{
   Zip.SaveZipToFile(OptimizationFile, FILE_COMMON);
   string f_totals = (string)Zip.TotalElements();
   printf("优化完成。保存的优化通关总数: " + f_totals);
}

此处, OptimizationFile 是设置优化名称的自定义字符串参数。默认时, 它为 "Optimization.zip"。因此, 在更新的 SmaSample 策略优化完成后, 将创建相应的 zip 存档。它可以在 Files 文件夹中找到, 并以标准方式打开:

图例 7. 优化文件的内部内容

如您所见, 所有已保存的通关都可以完美存储, 展示出 3 到 5 倍的高压缩比。 

将这些数据收集并保存到硬盘后, 需要将它们加载到另一个程序中并对其进行分析。这将在下一节中讨论。


创建策略分析器

在前一章节中, 已创建了一个包含所有通关信息的 zip 存档。这些信息应该现在就处理。为此目的, 我们创建一个名为 M-Tester 分析器 的特殊程序。它将加载生成的存档并将每次通关显示为便利的余额图表。M-Tester 分析器还将计算所选通关的汇总统计。

整个测试组合的关键特征之一是同时存储所有通关信息的能力。这意味着仅执行一次优化就足够了。所有通关信息将保存在一个存档中并传送给用户。所有通关稍后都可以从这个优化中加载, 并且可以查看其统计数据, 而无需再花时间再次运行策略测试器程序。

分析器的动作序列:

  1. 加载选定的优化存档
  2. 在此存档中选择一个优化通关
  3. 根据存在的成交绘制虚拟余额的动态图表
  4. 计算通关的基本统计数据, 包括成交数量, 利润总计, 亏损总计, 盈利因子, 收益预期等参数。
  5. 在主程序窗口中以表格的形式输出计算的统计数据。

有必要为用户提供从存档中选择任意通关的工具: 我们提供从当前通关到下一个或上一个通关的简单过渡, 以及设定自定义通关编号的能力。

该程序将基于 CPanel 图形引擎。目前, 尚无该函数库的专著, 但是它很易于学习, 结构紧凑, 并且已经在各种项目和文章中反复使用。

分析器的主代码位于自 CElChart 派生出的 CAnalizePanel 类中。分析器本身是以智能系统的形式实现的。主主要的智能系统文件启动分析仪的图形窗口。此为主要的智能系统文件:

//+------------------------------------------------------------------+
//|                                                    mAnalizer.mq5 |
//|                                   版权所有 2017, MetaQuotes 软件公司|
//|                                              http://www.mql5.com |
//+------------------------------------------------------------------+
#include "mAnalizerPanel.mqh"
CAnalyzePanel Panel;
input string FilePasses = "Optimization.zip";
//+------------------------------------------------------------------+
//| 智能系统初始化函数                                                  |
//+------------------------------------------------------------------+
int OnInit()
  {
//---
   Panel.Width(800);
   Panel.Height(630);
   Panel.XCoord(10);
   Panel.YCoord(20);
   Panel.LoadFilePasses(FilePasses);
   Panel.Show();
   ChartRedraw();
//---
   return(INIT_SUCCEEDED);
  }
//+------------------------------------------------------------------+
//| 智能系统逆初函数                                                    |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
{
   Panel.Hide();
}

//+------------------------------------------------------------------+
//| ChartEvent 函数                                                   |
//+------------------------------------------------------------------+
void OnChartEvent(const int id,
                  const long &lparam,
                  const double &dparam,
                  const string &sparam)
{
   switch(id)
   {
      case CHARTEVENT_OBJECT_ENDEDIT:
      {
         CEventChartEndEdit event(sparam);
         Panel.Event(&event);
         break;
      }
      case CHARTEVENT_OBJECT_CLICK:
      {
         CEventChartObjClick event(sparam);
         Panel.Event(&event);
         break;
      }
   }
   ChartRedraw();
}
//+------------------------------------------------------------------+


如您所见, 代码非常简单。创建一个 CAnalyzePanel 类型的对象。然后, 它的大小在 OnInit 方法中设置, 然后显示在当前图表 (Show 方法) 上。在来自图表的所有事件中, 只对两个感兴趣: 文本输入结束, 和点击图形对象。这些事件被转换成一个特殊的 CEvent 对象并传递给面板 (Panel.Event(...))。分析器面板接收这些事件并对其进行处理。

现在我们来描述分析器面板本身。它由一个大的 CAnalyzePanel 类组成, 所以其全部内容将不会被发布。它的完整代码附在文章的末尾, 供有兴趣的人参阅。在此仅提供简要的操作说明, 使用如下类原型:

//+------------------------------------------------------------------+
//|                                                    mAnalizer.mq5 |
//|                                   版权所有 2017, MetaQuotes 软件公司|
//|                                              http://www.mql5.com |
//+------------------------------------------------------------------+
#include <Panel\ElChart.mqh>
#include <Panel\ElButton.mqh>
#include <Graphics\Graphic.mqh>
#include "ListPass.mqh"
#include "TradeAnalyze.mqh"
//+------------------------------------------------------------------+
//| 面板用于分析数学分析器的通关                                          |
//+------------------------------------------------------------------+
class CAnalizePanel : public CElChart
{
private:
   //-- 元素和它们的统计数组
   CArrayObj      m_stat_descr;     // 统计说明
   CArrayObj      m_stat_all;       // 所有成交的统计值
   CArrayObj      m_stat_long;      // 所有多头成交的统计值
   CArrayObj      m_stat_short;     // 所有空头成交的统计值
   CTradeAnalize  m_analize;        // 统计计算模块
   //-- 图形控件
   CElChart       m_name_analyze;   // 主窗口名称
   CElChart       m_np;             // "Pass #" 文本
   CElChart       m_of_pass;        // "of ### passes" 文本
   CElChart       m_pass_index;     // 通关编号输入框
   CElButton      m_btn_next;       // "下一个通关" 按钮
   CElButton      m_btn_prev;       // "前一个通关" 按钮
   CGraphic       m_graphic;        // 余额动态图表
   //-- 底层结构
   CListPass      m_passes;         // 通关清单
   int            m_curr_pass;      // 当前通关索引
   CCurve*        m_balance_hist;   // 图表上的余额动态曲线
   bool           IsEndEditPass(CEvent* event);
   bool           IsClick(CEvent* event, CElChart* el);
   void           NextPass(void);
   void           PrevPass(void);
   int            GetCorrectPass(string text);
   void           RedrawGraphic(void);
   void           RedrawCurrPass(void);
   void           PlotStatistic(void);
   string         TypeStatToString(ENUM_MSTAT_TYPE type);
   void           CreateStatElements(void);
   string         ValueToString(double value, ENUM_MSTAT_TYPE type);
public:
                  CAnalizePanel(void);
   bool           LoadFilePasses(string file_name, int file_common = FILE_COMMON);
   virtual void   OnShow();
   virtual void   OnHide();
   virtual void   Event(CEvent *event);
};

如您所见, 该类的主要操作隐藏在内部。在公共方法中, 主要一个是加载包含优化通关信息的 zip 文件。该类的所有工作可以分为三部分:

  1. 创建图表并为其添加余额图。
  2. 以 CElChart 控件的形式创建文本标签, 其中显示测试统计信息。
  3. 实际计算通关统计。

我们来简要介绍这些部分。 

有必要创建相当多的控件来显示收集到的每次通关的所有统计信息。分析器显示十个基本统计参数。另外, 可分别针对所有成交、买入和卖出成交计算每个参数。需要 10 个额外的标签来显示指标的名称。因此, 有必要创建 40 个文本标签。为了避免手工创建每个控件, 我们来创建一个自动化过程。为此, 每个计算出的统计参数将为自己分配一个来自特殊枚举的标识符:

//+------------------------------------------------------------------+
//| 统计值类型的标识符                                                  |
//+------------------------------------------------------------------+
enum ENUM_MSTAT_TYPE
{
   MSTAT_PROFIT,
   MSTAT_ALL_WINS_MONEY,
   MSTAT_ALL_LOSS_MONEY,
   MSTAT_TRADERS_TOTAL,
   MSTAT_WIN_TRADERS,
   MSTAT_LOSS_TRADERS,   
   MSTAT_MAX_PROFIT,
   MSTAT_MAX_LOSS,
   MSTAT_PROFIT_FACTOR,
   MSTAT_MATH_EXP,   
};
#define MSTAT_ELEMENTS_TOTAL 10

另外, 为计算方向定义一个标识符:

//+------------------------------------------------------------------+
//| 可以为三个方向之一计算统计                                            |
//+------------------------------------------------------------------+
enum ENUM_MSTATE_DIRECT
{
   MSTATE_DIRECT_ALL,      // 对于所有成交
   MSTATE_DIRECT_LONG,     // 仅针对买入成交
   MSTATE_DIRECT_SHORT,    // 仅针对卖出成交
};

该面板包含四组控件, 每组控件都位于其自身的数组中:

  • 显示统计信息名称的控件 (m_stat_descr数组)
  • 显示所有成交统计值的控件 (m_stat_all 数组)
  • 显示多头成交统计值的控件 (m_stat_long 数组)
  • 显示空头成交统计值的控件 (m_stat_short 数组)

所有这些控件都是在 CAnalyzePanel::CreateStatElements(void) 方法的首次启动时创建的。

一旦所有的控件都被创建, 它们应该被填充正确的值。这些值的计算委托给外部 CTradeAnalize 类:

#include <Arrays\ArrayObj.mqh>
#include <Dictionary.mqh>
#include "..\MTester\Mposition.mqh"
//+------------------------------------------------------------------+
//| 辅助控件包含必要的字段                                               |
//+------------------------------------------------------------------+
class CDiffValues : public CObject
{
public:
   double all;
   double sell;
   double buy;
   CDiffValues(void) : all(0), buy(0), sell(0)
   {
   }
};
//+------------------------------------------------------------------+
//| 统计分析类                                                         |
//+------------------------------------------------------------------+
class CTradeAnalize
{
private:
   CDictionary m_values;
   
public:
   void     CalculateValues(CArrayObj* history);
   double   GetStatistic(ENUM_MSTAT_TYPE type, ENUM_MSTATE_DIRECT direct);
};
//+------------------------------------------------------------------+
//| 计算统计值                                                         |
//+------------------------------------------------------------------+
double CTradeAnalize::GetStatistic(ENUM_MSTAT_TYPE type, ENUM_MSTATE_DIRECT direct)
{
   CDiffValues* value = m_values.GetObjectByKey(type);
   switch(direct)
   {
      case MSTATE_DIRECT_ALL:
         return value.all;
      case MSTATE_DIRECT_LONG:
         return value.buy;
      case MSTATE_DIRECT_SHORT:
         return value.sell;
   }
   return EMPTY_VALUE;
}
//+------------------------------------------------------------------+
//| 计算每个方向的成交数量                                               |
//+------------------------------------------------------------------+
void CTradeAnalize::CalculateValues(CArrayObj *history)
{
   m_values.Clear();
   for(int i = 0; i < MSTAT_ELEMENTS_TOTAL; i++)
      m_values.AddObject(i, new CDiffValues());
   CDiffValues* profit = m_values.GetObjectByKey(MSTAT_PROFIT);
   CDiffValues* wins_money = m_values.GetObjectByKey(MSTAT_ALL_WINS_MONEY);
   CDiffValues* loss_money = m_values.GetObjectByKey(MSTAT_ALL_LOSS_MONEY);
   CDiffValues* total_traders = m_values.GetObjectByKey(MSTAT_TRADERS_TOTAL);
   CDiffValues* win_traders = m_values.GetObjectByKey(MSTAT_WIN_TRADERS);
   CDiffValues* loss_traders = m_values.GetObjectByKey(MSTAT_LOSS_TRADERS);
   CDiffValues* max_profit = m_values.GetObjectByKey(MSTAT_MAX_PROFIT);
   CDiffValues* max_loss = m_values.GetObjectByKey(MSTAT_MAX_LOSS);
   CDiffValues* pf = m_values.GetObjectByKey(MSTAT_PROFIT_FACTOR);
   CDiffValues* mexp = m_values.GetObjectByKey(MSTAT_MATH_EXP);
   total_traders.all = history.Total();
   for(int i = 0; i < history.Total(); i++)
   {
      CMposition* pos = history.At(i);
      profit.all += pos.Profit();
      if(pos.PositionType() == POSITION_TYPE_BUY)
      {
         if(pos.Profit() > 0)
         {
            win_traders.buy++;
            wins_money.buy += pos.Profit();
         }
         else
         {
            loss_traders.buy++;
            loss_money.buy += pos.Profit();
         }
         total_traders.buy++;
         profit.buy += pos.Profit();
      }
      else
      {
         if(pos.Profit() > 0)
         {
            win_traders.sell++;
            wins_money.sell += pos.Profit();
         }
         else
         {
            loss_traders.sell++;
            loss_money.sell += pos.Profit();
         }
         total_traders.sell++;
         profit.sell += pos.Profit();
      }
      if(pos.Profit() > 0)
      {
         win_traders.all++;
         wins_money.all += pos.Profit();
      }
      else
      {
         loss_traders.all++;
         loss_money.all += pos.Profit();
      }
      if(pos.Profit() > 0 && max_profit.all < pos.Profit())
         max_profit.all = pos.Profit();
      if(pos.Profit() < 0 && max_loss.all > pos.Profit())
         max_loss.all = pos.Profit();
   }
   mexp.all = profit.all/total_traders.all;
   mexp.buy = profit.buy/total_traders.buy;
   mexp.sell = profit.sell/total_traders.sell;
   pf.all = wins_money.all/loss_money.all;
   pf.buy = wins_money.buy/loss_money.buy;
   pf.sell = wins_money.sell/loss_money.sell;
}

计算本身由 CalculateValues 方法执行。它应该传递一个包含 CMposition 控件的 CArrayObj 数组。但是这个虚拟仓位数组从何而来?

事实是 CAnalyzePanel 类包含另一个类 — CListPass。它加载 zip 存档并创建通关集合。这个类十分简单:

//+------------------------------------------------------------------+
//|                                                    Optimazer.mq5 |
//|                                   版权所有 2017, MetaQuotes 软件公司|
//|                                              http://www.mql5.com |
//+------------------------------------------------------------------+
#include <Zip\Zip.mqh>
#include <Dictionary.mqh>
#include "..\MTester\MTrade.mqh"
//+------------------------------------------------------------------+
//| 存储优化通关清单                                                    |
//+------------------------------------------------------------------+
class CListPass
{
private:
   CZip        m_zip_passes;  // 所有优化通关的存档
   CDictionary m_passes;      // 加载历史仓位
public:
   bool        LoadOptimazeFile(string file_name, int file_common = FILE_COMMON);
   int         PassTotal(void);
   CArrayObj*  PassAt(int index);
};
//+------------------------------------------------------------------+
//| 从 zip 存档加载优化通关清单                                          |
//+------------------------------------------------------------------+
bool CListPass::LoadOptimazeFile(string file_name,int file_common=FILE_COMMON)
{
   m_zip_passes.Clear();
   if(!m_zip_passes.LoadZipFromFile(file_name, file_common))
   {     
      printf("加载优化文件失败。最后错误");
      return false;
   }
   return true;
}
//+------------------------------------------------------------------+
//| 通关数量                                                           |
//+------------------------------------------------------------------+
int CListPass::PassTotal(void)
{
   return m_zip_passes.TotalElements();
}
//+------------------------------------------------------------------+
//| 返回指定索引处通关的成交清单                                          |
//+------------------------------------------------------------------+
CArrayObj* CListPass::PassAt(int index)
{
   if(!m_passes.ContainsKey(index))
   {
      CZipFile* zip_file = m_zip_passes.ElementAt(index);
      uchar array[];
      zip_file.GetUnpackFile(array);
      CMtrade* trade = new CMtrade();
      trade.FromCharArray(array);
      m_passes.AddObject(index, trade);
   }
   CMtrade* trade = m_passes.GetObjectByKey(index);
   //printf("交易总计: " + (string)trade.History.Total());
   return &trade.History;
}

如其所见, CListPass 类加载优化存档, 但不会将其解压缩。这意味着即使在计算机内存中, 所有数据都以压缩格式存储, 这可以节省计算机的内存。请求的通关才会被解压缩并转换成 CMtrade 对象, 之后以未压缩的形式保存在内部存储器中。下次调用这个控件时, 不需要解压。

再次, 引用 CAnalyzePanel 类。我们现在知道仓位从何处加载 (CListPass 类), 以及它们的统计数据是如何计算的 (CTradeAnalyze 类)。在创建图形控件之后, 仍然需要填充正确的值。这由 CAnalyzePanel::PlotStatistic(void) 方法完成:

//+------------------------------------------------------------------+
//| 显示统计数据                                                       |
//+------------------------------------------------------------------+
void CAnalyzePanel::PlotStatistic(void)
{
   if(m_stat_descr.Total() == 0)
      CreateStatElements();
   CArrayObj* history = m_passes.PassAt(m_curr_pass-1);
   m_analize.CalculateValues(history);
   for(int i = 0; i < MSTAT_ELEMENTS_TOTAL; i++)
   {
      ENUM_MSTAT_TYPE stat_type = (ENUM_MSTAT_TYPE)i;
      //-- 所有交易
      CElChart* el = m_stat_all.At(i);
      string v = ValueToString(m_analize.GetStatistic(stat_type, MSTATE_DIRECT_ALL), stat_type);
      el.Text(v);
      //-- 多头交易
      el = m_stat_long.At(i);
      v = ValueToString(m_analize.GetStatistic(stat_type, MSTATE_DIRECT_LONG), stat_type);
      el.Text(v);
      //-- 空头交易
      el = m_stat_short.At(i);
      v = ValueToString(m_analize.GetStatistic(stat_type, MSTATE_DIRECT_SHORT), stat_type);
      el.Text(v);
   }
}

分析器面板操作所需的所有基本控件均已研究过了。描述结果不一致, 但这就是编程的本质: 所有元素都是相互关联的, 有时必须同时描述所有元素。

因此, 现在是时候在图表上启动分析器了。在动手之前, 请确保具有优化结果的 zip 存档已在 FILE_COMMON 目录中就绪。默认情况下, 分析器加载 "Optimization.zip" 文件, 该文件必须位于共有目录中。

当切换通关时可以看到所实现功能的最大效果。图表和统计信息会自动更新。下面的截图显示了这一时刻:


图例 8. 在数学计算分析器中切换通关

为了更好地理解面板操作, 下面是含有工具提示的图形解读: 主要控件由边框轮廓围绕, 表明该类和方法负责控件的每一组:


图例 9. 界面主控件

总而言之, 我们描述一下最终项目的结构。所有源代码都位于 MTester.zip 存档中。该项目本身位于 MQL5\Experts\MTester 文件夹中。然而, 像其它复杂的程序一样, 该项目需要包含额外的函数库。标准 MetaTrader 5 软件包中未提供的文件已保存在此存档中并置于 MQL5\Include 文件夹中。首先, 它是 CPanel 图形库 (位置: MQL5\Include\Panel)。此外, 还包括用于处理 zip 存档 (MQL5\Include\Zip) 的函数库和用于组织关联数组的类 (MQL5\Include\Dictionary)。为了方便用户, 创建了两个 MQL5 项目。这是 MetaTrader 5 的一项新功能, 最近才实现。第一个项目称为 MTester, 包含策略测试器和策略本身, 它基于移动平均线 (SmaSample.mq5) 的交汇。第二个项目称为 MAnalyzer, 包含分析器面板的源代码。

除了源代码之外, 存档文件还包含具有优化结果的 Optimization.zip 文件, 策略测试数据大约有 160 次通关。这可以快速检查通关分析器的功能, 而无需执行新的优化。该文件位于 MQL5\Files 中。


结束语

总之, 此处是文章中所描述材料的摘要。

  • 由于舍弃了交易环境模拟, 数学计算测试器速度很快。这为创建自定义高性能算法来测试简单交易策略提供了良好的基础。不过, 由于对所执行的交易操作缺乏正确性控制, 它有可能无意中 "窥探未来" — 意指报价尚未到达。这种 "圣杯 (Grails)" 中的错误很难识别, 但这是高性能的代价。
  • 从数学计算测试器中访问交易环境是不可能的。因此, 还原所需金融产品的报价是不可能的。所以, 在此模式下, 需要预先手工下载所需数据, 并使用自定义函数库来计算指标。文章展示了如何准备数据, 如何有效地压缩数据, 以及如何将它们集成到程序执行模块中。这项技术对于所有想要在程序中分发额外数据的人士都是有用的, 这些数据是其操作所必需的。
  • 在数学计算模式下, 也无法访问标准指标。因此, 需要手工计算必要的指标。但是, 速度也非常重要。这令手工计算智能系统内部的指标不仅仅是唯一的, 而且在速度方面是最佳解决方案。幸运地是, 环形缓冲库能够在一个恒定的时间内有效地计算出所有必要的指标。
  • MetaTrader 5 中的分帧生成模式功能强大, 虽然是一个复杂的机制, 但它为用户提供了编写自定义分析算法的绝佳机会。例如, 可以创建一个自定义策略测试器, 而这已在本文中展示。为了充分发挥分帧生成模式的潜力, 您需要能够充分利用二进制数据。通过使用这种数据类型的能力, 可以生成复杂的数据 — 例如仓位清单。文章展示: 如何创建一个复杂的自定义数据类型 (仓位 CMPosition 类); 如何将其转换为字节表示并将其添加到分帧; 如何从分帧中获取一个字节数组并将它们转换回自定义仓位清单。
  • 数据存储系统是策略测试器最重要的部分之一。显然, 测试过程中获得的数据量是巨大的: 每次测试可能包括数百次甚至数千次通关。且每次通关都包含很多成交, 可以轻松达到数万笔。整个项目的成功取决于这些信息的存储和分发效率。因此, 选用了一个 zip 存档。由于事实上 MQL5 拥有处理这类文件的强大且快速的函数库, 因此可以轻松组织自定义文件, 存储优化通关信息。每次优化都是包含所有通关信息的单一 zip 文件。每个通关信息由一个压缩文件表示。存档导致高数据压缩, 即便是大规模优化, 也能控制在适度的尺寸。
  • 创建自定义策略测试器还不够。分析这些优化结果需要单独的子系统。这种子系统以 M-Tester 分析器程序的形式实现。这是一个单独的程序模块, 它将优化结果作为 zip 存档文件加载并输出到图表中, 显示每次通关的基本统计信息。M-Tester 分析器基于若干个类和 CPanel 图形库。它是一个简单而方便的函数库, 可用于快速构建强大的图形界面。使用系统库 CGraphic, 分析器显示余额动态信息图。

尽管获得了令人印象深刻的结果, 并且基于数学计算的测试器实际上盈得了利润, 但它仍然缺少许多必要的东西。以下一些组件需要优先添加到下一个版本。

  • 品种信息 (名称, 逐笔报价值, 品种, 点差等)。这些信息对于计算可能的佣金, 点差和掉期利率是必要的。以存款货币计算利润也是必需的 (目前, 利润是以点数计算的)。
  • 每次通关的有关策略及其参数的信息。不仅要知道策略的结果, 还要知道其所有参数值。为此, 生成的报告还应该有一个集成到其内的附加数据类型。
  • 控制所执行操作的正确性。在这个阶段, 很容易发生 "窥探未来", 结果是出现一个 "圣杯", 而这与现实无关。未来版本至少需要一个最低限度的控制机制。然而, 确定它应该像什么样子仍然很困难。
  • 报告生成机制与实际策略测试器的集成。没有什么能够阻止我们将标准 MetaTrader 5 策略测试器中获得的结果转换为所开发的报告格式。这令我们能够使用 M-Trade 分析器来分析可靠的测试结果。因此, 将会有若干个测试系统和一个分析系统。
  • M-Trade 分析器的进一步发展。程序目前只有基本功能。这些显然不足以完整地处理数据。有必要为买卖成交添加额外的统计数据和单独的余额图。例如, 学习如何在文本文件中保存成交历史, 然后将其加载到 Excel 中也是合理的。
M-Tester 的所有主要方面, 以及其进一步发展的前景均已研究完毕。如果提议的主题足够有趣, 本文将会继续。已经完成了很多工作, 但仍有更多事情尚未完结。我们希望推出新版 M-Tester 的时刻尽早到来!