English Русский Español Deutsch 日本語 Português
preview
开发多币种 EA 交易系统(第 16 部分):不同报价历史对测试结果的影响

开发多币种 EA 交易系统(第 16 部分):不同报价历史对测试结果的影响

MetaTrader 5测试者 | 11 三月 2025, 09:05
415 0
Yuriy Bykov
Yuriy Bykov

概述

上一篇文章中,我们开始准备在真实账户上进行交易的多币种 EA。作为准备过程的一部分,我们添加了对不同交易工具名称的支持、当您想要更改交易策略设置时自动完成交易、以及由于各种原因重启后正确恢复 EA。

准备工作并未就此结束。我们已经概述了一些必要的步骤,但稍后我们将再次讨论。现在让我们来看看这样一个重要的方面,即确保不同经纪商的结果相似。众所周知,不同经纪商的交易工具报价并不相同。因此,通过对一些报价进行测试和优化,我们专门为它们选择了最佳参数。当然,我们希望当我们开始交易其他报价时,它们与用于测试的报价的差异很小,因此,交易结果的差异也将是微不足道的。 

然而,这是一个很重要的问题,不能没有详细的研究。那么,让我们看看我们的 EA 在测试不同经纪商的报价时是如何表现的。


比较结果

首先,让我们根据 MetaQuotes-Demo 服务器的报价启动我们的 EA。首次启动时启用了风险管理器。然而,展望未来,我们会说,在其他报价中,风险管理器的交易完成时间明显早于测试期结束,因此我们将禁用它以了解全貌。这样我们可以确保更公平地比较结果。结果如下:


图 1.不使用风险管理器的 MetaQuotes-Demo 服务器报价测试结果

现在,让我们将终端连接到另一个经纪商的真实服务器,并使用相同的参数再次运行 EA 测试:

图 2.在没有风险管理器的情况下对另一家经纪商的真实服务器的报价进行测试的结果

这是一个意想不到的转折。账户在不到一年的时间里就被完全清空了。让我们试着了解这种行为背后的原因,以便我们能够了解是否有可能以某种方式纠正这种情况。


寻找原因

我们将已完成通过的测试报告保存为 XML 文件,打开它们并找到已完成交易列表的开始位置。排列打开的文件窗口,以便我们可以同时看到两份报告的交易列表的顶部:

图 3.EA 在测试来自不同服务器的报价时执行的交易列表的顶部

甚至从报告的前几行就可以清楚地看出,这些仓位是在不同的时间开立的。因此,如果不同服务器上相同时刻的报价有任何差异,它们很可能不会像不同的开放时间那样产生破坏性的影响。

让我们看看在我们的策略中,开仓时刻是在哪里决定的。我们应该看一下实现 SimpleVolumesStrategy.mqh 交易策略单个实例的类的文件。如果我们查看代码,我们会发现 SignalForOpen() 方法返回开启信号:

//+------------------------------------------------------------------+
//| Signal for opening pending orders                                |
//+------------------------------------------------------------------+
int CSimpleVolumesStrategy::SignalForOpen() {
// By default, there is no signal
   int signal = 0;

// Copy volume values from the indicator buffer to the receiving array
   int res = CopyBuffer(m_iVolumesHandle, 0, 0, m_signalPeriod, m_volumes);

// If the required amount of numbers have been copied
   if(res == m_signalPeriod) {
      // Calculate their average value
      double avrVolume = ArrayAverage(m_volumes);

      // If the current volume exceeds the specified level, then
      if(m_volumes[0] > avrVolume * (1 + m_signalDeviation + m_ordersTotal * m_signaAddlDeviation)) {
         // if the opening price of the candle is less than the current (closing) price, then 
         if(iOpen(m_symbol, m_timeframe, 0) < iClose(m_symbol, m_timeframe, 0)) {
            signal = 1; // buy signal
         } else {
            signal = -1; // otherwise, sell signal
         }
      }
   }

   return signal;
}

我们看到,开仓信号是由当前交易工具的报价量值决定的。价格(当前价格和过去价格)不参与开仓信号的形成。更确切地说,它们的参与是在确定需要开仓之后进行的,并且只影响开仓的方向。因此,问题似乎恰恰在于从不同服务器接收到的报价量值存在很大差异。

这是完全有可能的,因为为了让不同的经纪商在视觉上匹配蜡烛图价格,每分钟仅提供四个正确的分时报价就足以为最短周期 M1 的蜡烛构建开盘价、收盘价、最高价和最低价。价格处于最低价和最高价之间指定限度内的中间报价数量并不重要。这意味着经纪商可以自由决定在历史记录中存储多少个分时报价以及它们如何在一个烛形内随时间分布。还值得记住的是,即使同一个经纪商,模拟账户和真实账户的服务器也可能不会显示完全相同的情况。

如果事实确实如此,那么我们就可以轻松绕过这个障碍。但要实施这样的解决方法,我们首先要确保我们正确地确定了观察到的差异的原因,这样我们的努力就不会白费。


绘制路径图

为了验证我们的假设,我们需要以下工具:

  • 保存历史。让我们在 EA 中添加在测试运行结束时保存交易历史(开仓和平仓)的功能。可以将内容保存到文件或数据库。由于此工具目前仅用作辅助工具,因此使用保存到文件可能更容易。如果我们想在未来更永久地使用它,我们可以扩展它,包括将历史记录保存到数据库的能力。

  • 交易回放。让我们创建一个新的 EA,它不包含任何开仓规则,只会复制开仓和平仓,从另一个 EA 保存的历史记录中读取它们。由于我们决定暂时将历史记录保存到文件中,因此此 EA 将接受包含交易历史记录的文件名作为输入,然后读取并执行其中保存的交易。

制作完这些工具后,我们将首先在测试器中启动我们的 EA,使用 MetaQuotes Demo 服务器的报价,并将此测试通过的交易历史保存到文件中。这将是第一个通过。然后,我们将使用保存的历史文件在测试器中对来自另一台服务器的报价启动一个新的交易回放 EA。这将是第二个通过。如果之前获得的交易结果的差异确实是由于非常不同的分时交易量数据造成的,并且价格本身大致相同,那么在第二轮中,我们应该得到与第一轮结果相似的结果。


保存历史

有不同的方法来实现历史记录保存。例如,我们可以将该方法添加到 CVirtualAdvisor 类中,该类将从 OnTester() 事件中调用。这种方法迫使我们扩展一个现有的类,添加它实际上不需要的功能。所以,让我们创建一个单独的类 CExpertHistory 来解决这个特定问题。我们不需要创建该类的多个对象,因此可以将其设为静态的,即只包含静态属性和方法。

该类只有一个主要的公共方法 — Export()。其余方法将起辅助作用。Export() 方法接收两个参数:要写入历史记录的文件的名称和使用共享终端数据文件夹的标志。默认文件名可能是一个空字符串。在这种情况下,将使用辅助方法 GetHistoryFileName() 来生成文件。使用写入共享文件夹的标志,我们可以选择将历史文件保存在何处 - 保存到共享数据文件夹或本地终端数据文件夹。默认情况下,标记值将设置为写入共享文件夹,因为在测试器中运行时,打开测试代理的本地文件夹比打开共享文件夹更困难。

作为类属性,我们需要在打开 CSV 文件进行写入时指定分隔符,打开的文件本身的句柄,以便在辅助方法中使用,以及要保存的数据的列名数组。

//+------------------------------------------------------------------+
//| Export trade history to file                                     |
//+------------------------------------------------------------------+
class CExpertHistory {
private:
   static string     s_sep;            // Separator character
   static int        s_file;           // File handle for writing
   static string     s_columnNames[];  // Array of column names

   // Write deal history to file
   static void       WriteDealsHistory();

   // Write one row of deal history to file 
   static void       WriteDealsHistoryRow(const string &fields[]);

   // Get the first deal date
   static datetime   GetStartDate();

   // Form a file name
   static string     GetHistoryFileName();

public:
   // Export deal history
   static void       Export(
      string exportFileName = "",   // File name for export. If empty, the name is generated
      int commonFlag = FILE_COMMON  // Save the file in shared data folder
   );
};

// Static class variables
string CExpertHistory::s_sep = ",";
int    CExpertHistory::s_file;
string CExpertHistory::s_columnNames[] = {"DATE", "TICKET", "TYPE",
                                          "SYMBOL", "VOLUME", "ENTRY", "PRICE",
                                          "STOPLOSS", "TAKEPROFIT", "PROFIT",
                                          "COMMISSION", "FEE", "SWAP",
                                          "MAGIC", "COMMENT"
                                         };

Export() 主方法中,我们将创建并打开一个具有指定或生成名称的文件进行写入。如果文件成功打开,则调用交易历史记录保存方法并关闭文件。

//+------------------------------------------------------------------+
//| Export deal history                                              |
//+------------------------------------------------------------------+
void CExpertHistory::Export(string exportFileName = "", int commonFlag = FILE_COMMON) {
   // If the file name is not specified, then generate it
   if(exportFileName == "") {
      exportFileName = GetHistoryFileName();
   }

   // Open the file for writing in the desired data folder
   s_file = FileOpen(exportFileName, commonFlag | FILE_WRITE | FILE_CSV | FILE_ANSI, s_sep);

   // If the file is open,
   if(s_file > 0) {
      // Set the deal history
      WriteDealsHistory();

      // Close the file
      FileClose(s_file);
   } else {
      PrintFormat(__FUNCTION__" | ERROR: Can't open file [%s]. Last error: %d",  exportFileName, GetLastError());
   }
}

GetHistoryFileName() 方法中,文件名由几个片段组成。首先,如果在 __VERSION__ 常量中指定了 EA 名称和版本,则将其添加到名称的开头。其次,添加交易历史的开始和结束日期。我们将通过调用 GetStartDate() 方法,根据历史上第一笔交易的日期来确定开始日期。结束日期将由当前时间确定,因为历史记录是在测试运行完成后导出的。换句话说,调用历史保存方法时的当前时间正是测试结束时间。第三,将一些通过特征的值添加到文件名中:初始余额、最终余额、回撤和夏普比率。

如果名称太长,就将其缩短到可接受的长度,并添加 .history.csv 扩展名。

//+------------------------------------------------------------------+
//| Form the file name                                               |
//+------------------------------------------------------------------+
string CExpertHistory::GetHistoryFileName() {
   // Take the EA name
   string fileName = MQLInfoString(MQL_PROGRAM_NAME);

   // If a version is specified, add it
#ifdef __VERSION__
   fileName += "." + __VERSION__;
#endif

   fileName += " ";

   // Add the history start and end date
   fileName += "[" + TimeToString(GetStartDate(), TIME_DATE);
   fileName += " - " + TimeToString(TimeCurrent(), TIME_DATE) + "]";

   fileName += " ";

   // Add some statistical characteristics
   fileName += "[" + DoubleToString(TesterStatistics(STAT_INITIAL_DEPOSIT), 0);
   fileName += ", " + DoubleToString(TesterStatistics(STAT_INITIAL_DEPOSIT) + TesterStatistics(STAT_PROFIT), 0);
   fileName += ", " + DoubleToString(TesterStatistics(STAT_EQUITY_DD_RELATIVE), 0);
   fileName += ", " + DoubleToString(TesterStatistics(STAT_SHARPE_RATIO), 2);
   fileName += "]";

   // If the name is too long, shorten it
   if(StringLen(fileName) > 255 - 13) {
      fileName = StringSubstr(fileName, 0, 255 - 13);
   }

   // Add extension
   fileName += ".history.csv";

   return fileName;
}

在将历史记录写入文件的方法中,首先写入标题,即包含数据列名称的行。然后,我们选择所有可用的历史记录,并开始遍历所有交易。获取每笔交易的属性。如果这是一笔交易开启或余额操作,则用所有交易属性的值形成一个数组,并将其传递给 WriteDealsHistoryRow() 方法以写入单笔交易。

//+------------------------------------------------------------------+
//| Write deal history to file                                       |
//+------------------------------------------------------------------+
void CExpertHistory::WriteDealsHistory() {
   // Write a header with column names
   WriteDealsHistoryRow(s_columnNames);

   // Variables for each deal properties
   uint     total;
   ulong    ticket = 0;
   long     entry;
   double   price;
   double   sl, tp;
   double   profit, commission, fee, swap;
   double   volume;
   datetime time;
   string   symbol;
   long     type, magic;
   string   comment;

   // Take the entire history
   HistorySelect(0, TimeCurrent());
   total = HistoryDealsTotal();

   // For all deals
   for(uint i = 0; i < total; i++) {
      // If the deal is successfully selected,
      if((ticket = HistoryDealGetTicket(i)) > 0) {
         // Get the values of its properties
         time  = (datetime)HistoryDealGetInteger(ticket, DEAL_TIME);
         type  = HistoryDealGetInteger(ticket, DEAL_TYPE);
         symbol = HistoryDealGetString(ticket, DEAL_SYMBOL);
         volume = HistoryDealGetDouble(ticket, DEAL_VOLUME);
         entry = HistoryDealGetInteger(ticket, DEAL_ENTRY);
         price = HistoryDealGetDouble(ticket, DEAL_PRICE);
         sl = HistoryDealGetDouble(ticket, DEAL_SL);
         tp = HistoryDealGetDouble(ticket, DEAL_TP);
         profit = HistoryDealGetDouble(ticket, DEAL_PROFIT);
         commission = HistoryDealGetDouble(ticket, DEAL_COMMISSION);
         fee = HistoryDealGetDouble(ticket, DEAL_FEE);
         swap = HistoryDealGetDouble(ticket, DEAL_SWAP);
         magic = HistoryDealGetInteger(ticket, DEAL_MAGIC);
         comment = HistoryDealGetString(ticket, DEAL_COMMENT);

         if(type == DEAL_TYPE_BUY || type == DEAL_TYPE_SELL || type == DEAL_TYPE_BALANCE) {
            // Replace the separator characters in the comment with a space
            StringReplace(comment, s_sep, " ");

            // Form an array of values for writing one deal to the file string
            string fields[] = {TimeToString(time, TIME_DATE | TIME_MINUTES | TIME_SECONDS),
                               IntegerToString(ticket), IntegerToString(type), symbol, DoubleToString(volume), IntegerToString(entry),
                               DoubleToString(price, 5), DoubleToString(sl, 5), DoubleToString(tp, 5), DoubleToString(profit),
                               DoubleToString(commission), DoubleToString(fee), DoubleToString(swap), IntegerToString(magic), comment
                              };

            // Set the values of a single deal to the file
            WriteDealsHistoryRow(fields);
         }
      }
   }
}

WriteDealsHistoryRow() 方法中,我们只需通过指定的分隔符将传递数组中的所有值组合成一个字符串,然后将其写入打开的 CSV 文件。为了连接,我们使用了一个新的宏 JOIN ,它被添加到 Macros.mqh 文件中的宏集合中。

//+------------------------------------------------------------------+
//| Write one row of deal history to the file                        |
//+------------------------------------------------------------------+
void CExpertHistory::WriteDealsHistoryRow(const string &fields[]) {
   // Row to be set
   string row = "";

   // Concatenate all array values into one row using a separator
   JOIN(fields, row, ",");

   // Write a row to the file
   FileWrite(s_file, row);
}

将更改保存到当前文件夹的 ExpertHistory.mqh 文件中。

现在我们只需要将文件连接到 EA 文件,并将调用 CExpertHistory:Export() 方法添加到 OnTester() 事件处理函数中:

...

#include "ExpertHistory.mqh"

...

//+------------------------------------------------------------------+
//| Test results                                                     |
//+------------------------------------------------------------------+
double OnTester(void) {
   CExpertHistory::Export();
   return expert.Tester();
}

将更改保存在当前文件夹中的 SimpleVolumesExpert.mq5 文件中。

让我们开始测试 EA。测试完成后,共享数据文件夹中出现了一个具有以下名称的文件

SimpleVolumesExpert.1.19 [2021.01.01 - 2022.12.30] [10000, 34518, 1294, 3.75].history.csv

名称显示,该交易历史涵盖两年(2021 年和 2022 年),起始账户余额为 10000 美元,最终账户余额为 34518 美元。在测试期间,净值最大相对回撤为 1294 美元,夏普比率为 3.75。如果我们在 Excel 中打开生成的文件,我们将看到以下内容:

图 4.将交易历史记录写到 CSV 文件的结果

数据看起来有效。现在让我们继续开发一个能够使用 CSV 文件在另一个账户上重现交易的 EA。


交易回放

让我们通过创建交易策略来开始实现新的 EA。事实上,遵循别人的指示,何时开仓以及开什么仓也可以被称为交易策略。如果信号的来源是可靠的,那么为什么不使用它呢。因此,让我们创建一个新类 CHistoryStrategy 并从 CVirtualStrategy 继承。至于方法,我们肯定需要实现一个构造函数、一个分时报价处理方法和一个转换为字符串的方法。虽然我们不需要最后一个,但是由于继承,它的存在是必需的,因为这个方法在父类中是抽象的。

我们只需要将以下属性添加到新类中:

  • m_symbols — 交易品种名称数组(交易工具);
  • m_history — 用于读取交易历史文件的二维数组(N 行 * 15 列);
  • m_totalDeals — 历史中的交易数量;
  • m_currentDeal — 当前交易的索引;
  • m_symbolInfo — 用于获取交易品种属性数据的对象。
这些属性的初始值将在构造函数中设置。
//+------------------------------------------------------------------+
//| Trading strategy for reproducing the history of deals            |
//+------------------------------------------------------------------+
class CHistoryStrategy : public CVirtualStrategy {
protected:
   string            m_symbols[];            // Symbols (trading instruments)
   string            m_history[][15];        // Array of deal history (N rows * 15 columns)
   int               m_totalDeals;           // Number of deals in history
   int               m_currentDeal;          // Current deal index

   CSymbolInfo       m_symbolInfo;           // Object for getting information about the symbol properties

public:
                     CHistoryStrategy(string p_params);        // Constructor
   virtual void      Tick() override;        // OnTick event handler
   virtual string    operator~() override;   // Convert object to string
};

策略构造函数应该接受一个参数 — 初始化字符串。这一要求也源于继承。初始化字符串应该包含所有必需的值。构造函数从字符串中读取它们并根据需要使用它们。恰巧,对于这个简单的策略,我们只需要在初始化字符串中传递一个值 — 历史文件的名称。该策略的所有进一步数据都将从该历史文件中获得。然后构造函数可以按以下方式实现:

//+------------------------------------------------------------------+
//| Constructor                                                      |
//+------------------------------------------------------------------+
CHistoryStrategy::CHistoryStrategy(string p_params) {
   m_params = p_params;

// Read the file name from the parameters
   string fileName = ReadString(p_params);

// If the name is read, then
   if(IsValid()) {
      // Attempting to open a file in the data folder
      int f = FileOpen(fileName, FILE_READ | FILE_CSV | FILE_ANSI | FILE_SHARE_READ, ',');

      // If failed to open a file, then try to open the file from the shared folder
      if(f == INVALID_HANDLE) {
         f = FileOpen(fileName, FILE_COMMON | FILE_READ | FILE_CSV | FILE_ANSI | FILE_SHARE_READ, ',');
      }

      // If this does not work, report an error and exit
      if(f == INVALID_HANDLE) {
         SetInvalid(__FUNCTION__,
                    StringFormat("ERROR: Can't open file %s from common folder %s, error code: %d",
                                 fileName, TerminalInfoString(TERMINAL_COMMONDATA_PATH), GetLastError()));
         return;
      }

      // Read the file up to the header string (usually it comes first)
      while(!FileIsEnding(f)) {
         string s = FileReadString(f);
         // If we find a header string, read the names of all columns without saving them
         if(s == "DATE") {
            FORI(14, FileReadString(f));
            break;
         }
      }

      // Read the remaining rows until the end of the file
      while(!FileIsEnding(f)) {
         // If the array for storing the read history is filled, increase its size
         if(m_totalDeals == ArraySize(m_history)) {

            ArrayResize(m_history, ArraySize(m_history) + 10000, 100000);
         }

         // Read 15 values from the next file string into the array string
         FORI(15, m_history[m_totalDeals][i] = FileReadString(f));

         // If the deal symbol is not empty,
         if(m_history[m_totalDeals][SYMBOL] != "") {
            // Add it to the symbol array if there is no such symbol there yet
            ADD(m_symbols, m_history[m_totalDeals][SYMBOL]);
         }

         // Increase the counter of read deals
         m_totalDeals++;
      }

      // Close the file
      FileClose(f);

      PrintFormat(__FUNCTION__" | OK: Found %d rows in %s", m_totalDeals, fileName);

      // If there are read deals except for the very first one (account top-up), then
      if(m_totalDeals > 1) {
         // Set the exact size for the history array
         ArrayResize(m_history, m_totalDeals);

         // Current time
         datetime ct = TimeCurrent();

         PrintFormat(__FUNCTION__" |\n"
                     "Start time in tester:  %s\n"
                     "Start time in history: %s",
                     TimeToString(ct, TIME_DATE), m_history[0][DATE]);

         // If the test start date is greater than the history start date, then report an error
         if(StringToTime(m_history[0][DATE]) < ct) {
            SetInvalid(__FUNCTION__,
                       StringFormat("ERROR: For this history file [%s] set start date less than %s",
                                    fileName, m_history[0][DATE]));
         }
      }

      // Create virtual positions for each symbol
      CVirtualReceiver::Get(GetPointer(this), m_orders, ArraySize(m_symbols));

      // Register the event handler for a new bar on the minimum timeframe
      FOREACH(m_symbols, IsNewBar(m_symbols[i], PERIOD_M1));
   }
}

在构造函数中,我们从初始化字符串中读取文件名并尝试打开它。如果文件从本地或共享数据文件夹成功打开,那么我们将读取其内容,并用它填充 m_history 数组。当我们读取时,我们还会填充交易品种名称的 m_symbols 数组:一旦遇到新名称,我们就立即将其添加到数组中。这是通过 ADD() 宏完成的。

在此过程中,我们利用 m_totalDeals 属性作为 m_history 数组第一维的索引来计算已读取交易条目的数量,该数组应用于记录有关下一笔交易的信息。读取完文件的所有内容后,我们将其关闭。

接下来,我们检查测试开始日期是否大于历史开始日期。我们不能允许这种情况发生,因为在这种情况下,不可能从历史的一开始就对一些交易进行建模。这很可能会导致测试期间的交易结果失真。因此,只有当交易历史开始时间不早于测试开始日期时,我们才允许构造函数创建有效对象。

构造函数中的关键点是严格根据历史中遇到的不同交易品种名称的数量分配虚拟仓位。由于该策略的目标是为每个交易品种提供所需的持仓量,因此每个交易品种仅使用一个虚拟仓位即可实现此目的。

报价处理方法只适用于读取交易的数组。由于我们可以在同一时刻同时打开/关闭多个交易品种,因此我们安排了一个循环来处理时间不大于当前时间的交易历史中的所有数据行。在当前时间增加并且出现时间已经到达的新交易时,剩余的交易条目将在以下时间点处理。

如果发现至少一个需要处理的交易,我们首先在 m_symbols 数组中找到它的交易品种和索引。利用该索引,我们将确定 m_orders 数组中的哪个虚拟仓位负责该交易品种。如果由于某种原因没有找到索引(如果一切正常,这种情况应该不会发生),那么我们将跳过该交易。我们还将跳过反映账户余额交易的交易。

现在,最有趣的部分开始了。我们需要处理已读取的交易。这里有两种可能的情况:这个交易品种没有打开的虚拟仓位,或者虚拟仓位已打开。

在前一种情况下,一切都很简单:我们按照交易的方向以对应的交易量开仓。在第二种情况下,我们可能需要增加给定交易品种的当前仓位的交易量或者减少它。而且,可能还需要将其减少到足以使开仓方向发生改变。

为了简化计算,我们将执行以下操作:

  • 将新交易的数量转换为“有符号”格式。也就是说,如果它是卖出方向,那么我们将使其交易量记为负数。
  • 我们将获得与新交易品种相同的未平仓交易量。CVirtualOrder::Volume() 方法立即以有符号格式的返回交易量。
  • 将已开仓的交易量添加到新开仓的交易量中。获取考虑到新交易后应保持开启的新交易量。这个交易量也将采用“有符号”格式。
  • 关闭未平仓的虚拟仓位。
  • 如果新交易量不等于零,则为该交易品种开设一个新的虚拟仓位。我们根据新交易量的交易品种确定其方向(正数 - 买入,负数 - 卖出)。将新交易量的模数作为交易量传递给虚拟开仓方法。

在此过程之后,增加历史中已处理交易的计数器,并继续进行下一个循环迭代。如果此时没有更多交易需要处理,或者历史上的交易已经结束,那么分时报价处理就完成了。

//+------------------------------------------------------------------+
//| OnTick event handler                                             |
//+------------------------------------------------------------------+
void CHistoryStrategy::Tick() override {
//---
   while(m_currentDeal < m_totalDeals && StringToTime(m_history[m_currentDeal][DATE]) <= TimeCurrent()) {
      // Deal symbol
      string symbol = m_history[m_currentDeal][SYMBOL];
      
      // Find the index of the current deal symbol in the array of symbols
      int index;
      FIND(m_symbols, symbol, index);

      // If not found, then skip the current deal
      if(index == -1) {
         m_currentDeal++;
         continue;
      }
      
      // Deal type
      ENUM_DEAL_TYPE type = (ENUM_DEAL_TYPE) StringToInteger(m_history[m_currentDeal][TYPE]);

      // Current deal volume
      double volume = NormalizeDouble(StringToDouble(m_history[m_currentDeal][VOLUME]), 2);

      // If this is a top-up/withdrawal, skip the deal
      if(volume == 0) {
         m_currentDeal++;
         continue;
      }

      // Report information about the read deal
      PrintFormat(__FUNCTION__" | Process deal #%d: %s %.2f %s",
                  m_currentDeal, (type == DEAL_TYPE_BUY ? "BUY" : (type == DEAL_TYPE_SELL ? "SELL" : EnumToString(type))),
                  volume, symbol);

      // If this is a sell deal, then make the volume negative
      if(type == DEAL_TYPE_SELL) {
         volume *= -1;
      }

      // If the virtual position for the current deal symbol is open,
      if(m_orders[index].IsOpen()) {
         // Add its volume to the volume of the current trade
         volume += m_orders[index].Volume();
         
         // Close the virtual position
         m_orders[index].Close();
      }

      // If the volume for the current symbol is not 0,
      if(MathAbs(volume) > 0.00001) {
         // Open a virtual position of the required volume and direction
         m_orders[index].Open(symbol, (volume > 0 ? ORDER_TYPE_BUY : ORDER_TYPE_SELL), MathAbs(volume));
      }

      // Increase the counter of handled deals
      m_currentDeal++;
   }
}

将获得的代码保存到当前文件夹的 HistoryStrategy.mqh 文件中。

现在让我们基于现有的 SimpleVolumesExpert.mq5 创建一个 EA 文件。为了获得所需的结果,我们需要向 EA 添加一个输入参数,其中我们可以指定包含历史记录的文件的名称。

input group "::: Testing the deal history"
input string historyFileName_    = "";    // File with history

负责从数据库加载策略初始化字符串的代码部分不再需要,因此我们将其删除。

我们需要在初始化字符串中设置 CHistoryStrategy 类策略的单个实例的创建。该策略接收带有历史记录的文件名作为参数:

// Prepare the initialization string for an EA with a group of several strategies
   string expertParams = StringFormat(
                            "class CVirtualAdvisor(\n"
                            "    class CVirtualStrategyGroup(\n"
                            "       [\n"
                            "        class CHistoryStrategy(\"%s\")\n"
                            "       ],%f\n"
                            "    ),\n"
                            "    class CVirtualRiskManager(\n"
                            "       %d,%.2f,%d,%.2f,%.2f,%d,%.2f,%.2f,%d,%.2f,%d,%.2f,%.2f"
                            "    )\n"
                            "    ,%d,%s,%d\n"
                            ")",
                            historyFileName_, scale_,
                            rmIsActive_, rmStartBaseBalance_,
                            rmCalcDailyLossLimit_, rmMaxDailyLossLimit_, rmCloseDailyPart_,
                            rmCalcOverallLossLimit_, rmMaxOverallLossLimit_, rmCloseOverallPart_,
                            rmCalcOverallProfitLimit_, rmMaxOverallProfitLimit_, rmMaxOverallProfitDate_,
                            rmMaxRestoreTime_, rmLastVirtualProfitFactor_,
                            magic_, "HistoryReceiver", useOnlyNewBars_
                         );

这样就完成了对 EA 文件的修改。将其在当前文件夹中保存为 HistoryReceiverExpert.mq5

现在我们有了一个可以重现交易历史的有效 EA。事实上,它还有更广泛的功能。尽管历史上的交易是基于固定余额的交易设置的,但我们可以很容易地看到,随着账户余额的增加,增加已开立头寸的数量时,交易结果会是什么样子。我们可以应用不同的风险管理器参数来评估其对交易的影响,尽管交易历史设置了不同的风险管理器参数(甚至禁用了风险管理器)。通过测试器后,交易历史记录会自动保存到一个新文件中。

但是,如果我们还不需要所有这些附加功能,不想使用风险管理器,也不喜欢与之相关的一堆未使用的输入参数,那么我们可以创建一个没有附加功能的新 EA 类。在这个类中,我们还可以摆脱保存状态和在图表上绘制虚拟仓位的界面,以及其他尚未使用的东西。

实现这样的类可能看起来像这样:

//+------------------------------------------------------------------+
//| Trade history replay EA class                                    |
//+------------------------------------------------------------------+
class CVirtualHistoryAdvisor : public CAdvisor {
protected:
   CVirtualReceiver *m_receiver;       // Receiver object that brings positions to the market
   bool              m_useOnlyNewBar;  // Handle only new bar ticks
   datetime          m_fromDate;       // Test start time

public:
   CVirtualHistoryAdvisor(string p_param);   // Constructor
   ~CVirtualHistoryAdvisor();                // Destructor

   virtual void      Tick() override;        // OnTick event handler
   virtual double    Tester() override;      // OnTester event handler

   virtual string    operator~() override;   // Convert object to string
};


//+------------------------------------------------------------------+
//| Constructor                                                      |
//+------------------------------------------------------------------+
CVirtualHistoryAdvisor::CVirtualHistoryAdvisor(string p_params) {
// Save the initialization string
   m_params = p_params;

// Read the file name from the initialization string
   string fileName = ReadString(p_params);

// Read the work flag only at the bar opening
   m_useOnlyNewBar = (bool) ReadLong(p_params);

// If there are no read errors,
   if(IsValid()) {
      if(!MQLInfoInteger(MQL_TESTER)) {
         // Otherwise, set the object state to invalid
         SetInvalid(__FUNCTION__, "ERROR: This expert can run only in tester");
         return;
      }

      if(fileName == "") {
         // Otherwise, set the object state to invalid
         SetInvalid(__FUNCTION__, "ERROR: Set file name with deals history in ");
         return;
      }

      string strategyParams = StringFormat("class CHistoryStrategy(\"%s\")", fileName);

      CREATE(CHistoryStrategy, strategy, strategyParams);

      Add(strategy);

      // Initialize the receiver with the static receiver
      m_receiver = CVirtualReceiver::Instance(65677);

      // Save the work (test) start time
      m_fromDate = TimeCurrent();
   }
}

//+------------------------------------------------------------------+
//| Destructor                                                       |
//+------------------------------------------------------------------+
void CVirtualHistoryAdvisor::~CVirtualHistoryAdvisor() {
   if(!!m_receiver)     delete m_receiver;      // Remove the recipient
   DestroyNewBar();           // Remove the new bar tracking objects 
}


//+------------------------------------------------------------------+
//| OnTick event handler                                             |
//+------------------------------------------------------------------+
void CVirtualHistoryAdvisor::Tick(void) {
// Define a new bar for all required symbols and timeframes
   bool isNewBar = UpdateNewBar();

// If there is no new bar anywhere, and we only work on new bars, then exit
   if(!isNewBar && m_useOnlyNewBar) {
      return;
   }

// Start handling in strategies
   CAdvisor::Tick();

// Receiver handles virtual positions
   m_receiver.Tick();

// Adjusting market volumes
   m_receiver.Correct();
}

//+------------------------------------------------------------------+
//| OnTester event handler                                           |
//+------------------------------------------------------------------+
double CVirtualHistoryAdvisor::Tester() {
// Maximum absolute drawdown
   double balanceDrawdown = TesterStatistics(STAT_EQUITY_DD);

// Profit
   double profit = TesterStatistics(STAT_PROFIT);

// Fixed balance for trading from settings
   double fixedBalance = CMoney::FixedBalance();

// The ratio of possible increase in position sizes for the drawdown of 10% of fixedBalance_
   double coeff = fixedBalance * 0.1 / MathMax(1, balanceDrawdown);

// Calculate the profit in annual terms
   long totalSeconds = TimeCurrent() - m_fromDate;
   double totalYears = totalSeconds / (365.0 * 24 * 3600);
   double fittedProfit = profit * coeff / totalYears;

// If it is not specified, then take the initial balance (although this will give a distorted result)
   if(fixedBalance < 1) {
      fixedBalance = TesterStatistics(STAT_INITIAL_DEPOSIT);
      balanceDrawdown = TesterStatistics(STAT_EQUITY_DDREL_PERCENT);
      coeff = 0.1 / balanceDrawdown;
      fittedProfit = fixedBalance * MathPow(1 + profit * coeff / fixedBalance, 1 / totalYears);
   }

   return fittedProfit;
}

//+------------------------------------------------------------------+
//| Convert an object to a string                                    |
//+------------------------------------------------------------------+
string CVirtualHistoryAdvisor::operator~() {
   return StringFormat("%s(%s)", typename(this), m_params);
}
//+------------------------------------------------------------------+

此类的 EA 在初始化字符串中只接受两个参数:历史文件的名称和仅在分钟柱打开时工作的标志。将代码保存到当前文件夹的 VirtualHistoryAdvisor.mqh 文件中。

与之前的版本相比,使用此类的 EA 文件也可以稍微缩短:

//+------------------------------------------------------------------+
//| Inputs                                                           |
//+------------------------------------------------------------------+
input group "::: Testing the deal history"
input string historyFileName_    = "";    // File with history
input group "::: Money management"
sinput double fixedBalance_      = 10000; // - Used deposit (0 - use all) in the account currency
input  double scale_             = 1.00;  // - Group scaling multiplier

input group "::: Other parameters"
input bool     useOnlyNewBars_   = true;  // - Work only at bar opening

datetime fromDate = TimeCurrent();        // Operation start time

CVirtualHistoryAdvisor     *expert;       // EA object

//+------------------------------------------------------------------+
//| Expert initialization function                                   |
//+------------------------------------------------------------------+
int OnInit() {
// Set parameters in the money management class
   CMoney::DepoPart(scale_);
   CMoney::FixedBalance(fixedBalance_);

// Prepare the initialization string for the deal history replay EA
   string expertParams = StringFormat(
                            "class CVirtualHistoryAdvisor(\"%s\",%f,%d)",
                            historyFileName_, useOnlyNewBars_
                         );

// Create an EA handling virtual positions
   expert = NEW(expertParams);

// If the EA is not created, then return an error
   if(!expert) return INIT_FAILED;

// Successful initialization
   return(INIT_SUCCEEDED);
}

//+------------------------------------------------------------------+
//| Expert tick function                                             |
//+------------------------------------------------------------------+
void OnTick() {
   expert.Tick();
}

//+------------------------------------------------------------------+
//| Expert deinitialization function                                 |
//+------------------------------------------------------------------+
void OnDeinit(const int reason) {
   if(!!expert) delete expert;
}

//+------------------------------------------------------------------+
//| Test results                                                     |
//+------------------------------------------------------------------+
double OnTester(void) {
   return expert.Tester();
}
//+------------------------------------------------------------------+

将代码保存到当前文件夹的 SimpleHistoryReceiverExpert.mq5 文件中。


测试结果

让我们启动其中一个已创建的 EA,并指定保存交易历史记录的文件的正确名称。首先,让我们在用于获取历史记录的同一报价服务器 (MetaQuotes-Demo) 上启动它。获得的测试结果与原始结果完全匹配!我必须承认,这甚至是一个出乎意料的好结果,表明计划的正确实施。 

现在让我们看看当我们在另一台服务器上运行 EA 时会发生什么:


图 5.重现另一家经纪商真实服务器的报价交易历史的结果

余额曲线图与 MetaQuotes-Demo 上的初始交易结果图表几乎没有区别。然而,数值略有不同。让我们再次查看原始值进行比较:


图 6.MetaQuotes-Demo 服务器报价的初步测试结果

我们看到总利润和归一化平均年利润以及夏普比率略有下降,回撤略有增加。然而,这些结果与我们最初在另一个经纪商的真实服务器上运行 EA 时看到的全部存款损失不可比。这非常令人鼓舞,并开辟了我们在准备 EA 进行真实交易时可能必须解决的新任务。


结论

现在是得出一些临时结论的时候了。我们能够证明,对于所使用的特定交易策略,更改报价服务器可能会产生非常可怕的后果。但是,在了解了这种行为的原因后,我们能够证明,如果我们将服务器上开仓信号的逻辑与原始报价分开,只将开仓和平仓操作传递给新服务器,那么交易结果再次变得接近。

为此,我们开发了两个新工具,允许我们在测试器通过后保存交易历史,然后根据保存的历史回放交易。但这些工具只能在测试器中使用。在真实交易中,它们毫无意义。现在,我们也可以开始在真实交易的 EA 之间实施这种责任分工,因为测试结果证实了使用这种方法的有效性。

我们需要将此 EA 拆分为两个独立的 EA。第一个会决定是否开仓并开仓,同时在我们认为最方便的报价服务器上工作。与此同时,它必须确保以第二个 EA 可以接受的形式广播开启的仓位列表。第二个 EA 将在另一个终端中工作,必要时连接到另一个报价服务器。它将不断保持与第一个 EA 广播的值相对应的未平仓头寸数量。这将有助于绕过我们在本文开头确定的限制。

我们可以更进一步。上述工作布局意味着两个终端应在一台计算机上工作,但这不是必需的。这些终端可以在不同的计算机上工作。最重要的是,第一个 EA 可以通过某些通道将仓位信息传递给第二个 EA。显然,这将不允许交易策略的成功运作,为此,坚持准确的开仓时间和价格至关重要。但我们最初专注于使用其他策略,这些策略不需要高精度的入场。因此,在安排这样的工作布局时,通信信道延迟不应成为障碍。

但我们不要过于超前,在接下来的文章中,我们将继续朝着选定的方向进行系统性的开发。

感谢您的关注!期待很快与您见面! 


存档内容

#
 名称
版本  描述   最近修改
 MQL5/Experts/Article.15330
1 Advisor.mqh 1.04 EA 基类 第 10 部分
2 Database.mqh 1.03 处理数据库的类 第 13 部分
3 ExpertHistory.mqh 1.00 用于将交易历史记录导出到文件的类 第 16 部分
4 Factorable.mqh 1.01 从字符串创建的对象的基类 第 10 部分
5 HistoryReceiverExpert.mq5 1.00 用于与风险管理器重现交易历史的 EA 第 16 部分 
6 HistoryStrategy.mqh  1.00 用于重现交易历史的交易策略类  第 16 部分
7 Interface.mqh 1.00 可视化各种对象的基类 第 4 部分
8 Macros.mqh 1.02 用于数组操作的有用的宏 第 16 部分 
9 Money.mqh 1.01  资金管理基础类 第 12 部分
10 NewBarEvent.mqh 1.00  用于定义特定交易品种的新柱形的类  第 8 部分
11 Receiver.mqh 1.04  将未平仓交易量转换为市场仓位的基类  第 12 部分
12 SimpleHistoryReceiverExpert.mq5 1.00 简化的EA,用于回放交易历史   第 16 部分
13 SimpleVolumesExpert.mq5 1.19 用于多组模型策略并行运行的 EA。参数应从优化数据库中加载。 第 16 部分
14 SimpleVolumesStrategy.mqh 1.09  使用分时交易量的交易策略类 第 15 部分
15 Strategy.mqh 1.04  交易策略基类 第 10 部分
16 TesterHandler.mqh  1.02 优化事件处理类  第 13 部分 
17 VirtualAdvisor.mqh  1.06  处理虚拟仓位(订单)的 EA 类 第 15 部分
18 VirtualChartOrder.mqh  1.00  图形虚拟仓位类 第 4 部分 
19 VirtualFactory.mqh 1.04  对象工厂类  第 16 部分
20 VirtualHistoryAdvisor.mqh 1.00  交易历史回放 EA 类  第 16 部分
21 VirtualInterface.mqh  1.00  EA GUI 类  第 4 部分 
22 VirtualOrder.mqh 1.04  虚拟订单和仓位类  第 8 部分
23 VirtualReceiver.mqh 1.03  将未平仓交易量转换为市场仓位的类(接收方)  第 12 部分
24 VirtualRiskManager.mqh  1.02  风险管理类(风险管理器)  第 15 部分
25 VirtualStrategy.mqh 1.05  具有虚拟仓位的交易策略类  第 15 部分
26 VirtualStrategyGroup.mqh  1.00  交易策略组类 第 11 部分 
27 VirtualSymbolReceiver.mqh  1.00 交易品种接收器类  第 3 部分
MQL5/Files 
1 SimpleVolumesExpert.1.19 [2021.01.01 - 2022.12.30] [10000, 34518, 1294, 3.75].history.csv    导出后获得的 SimpleVolumesExpert.mq5 EA 交易的历史记录。它可用于使用 SimpleHistoryReceiverExpert.mq5 或 HistoryReceiverExpert.mq5 EA 在测试器中回放交易  

本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/15330

附加的文件 |
MQL5.zip (163.78 KB)
让新闻交易轻松上手(第3部分):执行交易 让新闻交易轻松上手(第3部分):执行交易
在本文中,我们的新闻交易EA将根据存储在数据库中的经济日历开始交易。此外,我们将改进EA的图表,以显示更多关于即将到来的经济日历事件的相关信息。
构建K线图趋势约束模型(第8部分):EA的开发(一) 构建K线图趋势约束模型(第8部分):EA的开发(一)
在本文中,我们将基于前文创建的指标,开发我们的第一个由MQL5语言编写的EA。我们将涵盖实现自动化交易所需的所有功能,包括风险管理。这将极大地帮助用户从手动交易转变为自动化交易系统。
您应当知道的 MQL5 向导技术(第 25 部分):多时间帧测试和交易 您应当知道的 MQL5 向导技术(第 25 部分):多时间帧测试和交易
默认情况下,由于组装类中使用了 MQL5 代码架构,故基于多时间帧策略,且由向导组装的智能系统无法进行测试。我们探索一种绕过该限制的方式,看看搭配二次移动平均线的情况下,研究运用多时间帧策略的可能性。
动物迁徙优化(AMO)算法 动物迁徙优化(AMO)算法
本文介绍了AMO算法,该算法通过模拟动物的季节性迁徙来寻找适合生存和繁殖的最优条件。AMO的主要特点包括使用拓扑邻域和概率更新机制,使得其易于实现,并且能够灵活应用于各种优化任务。