应用网络函数,或无需 DLL 的 MySQL:第 II 部分 - 监视信号属性变化的程序

18 五月 2020, 09:24
Serhii Shevchuk
0
945

内容

概述

在前一部分当中,我们研究了 MySQL 连通器的实现。 现在是研究其应用示例的时候了。 最简单、最明显的方法是收集信号属性值,并拥有查看其后续变化的能力。 对于大多数帐户,终端中可用的信号超过 100 个,而每个信号都有 20 多个参数。 这意味着我们拥有足够的数据。 如果用户需要观察并未显示在信号网页上的属性变化,则所实现的示例具有重大实际意义。 这些变化可能包括杠杆、评级、订阅者数量等等。

为了收集数据,我们要实现一项服务,该服务定期请求信号属性,并与前值进行比较,若检测到差异时则将整个数组发送到数据库。

若要查看属性动态,应编写一款以图形方式展现所选属性变化的 EA。 另外,我们要实现运用条件数据库查询,按一些属性值进行信号排序的能力。

在实现过程中,我们将找出为什么在某些情况下需要保持活跃持续连接模式,以及多条查询的原因。


数据收集服务

服务目标如下:

  1. 定期请求终端中所有可用信号的属性
  2. 将它们的值与前值进行比较
  3. 若检测到差异,则将整个值数组写入数据库
  4. 发生错误时通知用户

在编辑器中创建一个新的服务并将其命名为 signals_to_db.mq5。 输入如下:

input string   inp_server     = "127.0.0.1";          // MySQL server address
input uint     inp_port       = 3306;                 // TCP port
input string   inp_login      = "admin";              // Login
input string   inp_password   = "12345";              // Password
input string   inp_db         = "signals_mt5";        // Database name
input bool     inp_creating   = true;                 // Allow creating tables
input uint     inp_period     = 30;                   // Signal loading period
input bool     inp_notifications = true;              // Send error notifications

除了网络设置,这里还有若干选项:

  • inp_creating — 在数据库中创建数据表的权限。 如果服务引用了不存在的数据表,则在参数等于 true 的情况下可以创建该数据表
  • inp_period — 请求信号属性的周期(以秒为单位)
  • inp_notifications — 发送有关 MySQL 服务器操作发生错误的通知权限


获取信号属性值

为了令服务正常工作,知晓终端何时更新信号属性值至关重要。 这会在两种情况下发生:
  • 在启动期间。
  • 终端操作期间,于“工具箱”窗口中的“信号”选项卡里定期刷新。 数据更新周期为 3 小时。

至少,这是截至撰写本文时终端版本中发生的情况。

我们感兴趣的有四种信号属性类型:

创建 ENUM_SIGNAL_BASE_DATETIME 类型是为了将 ENUM_SIGNAL_BASE_INTEGER 属性转换为时间字符串,而不是一个整数值。

出于便洁起见,将同类型属性的枚举值分解到数组(四个属性类型 — 四个数组)。 每个枚举都带有属性的文本描述,它也是数据库里数据表的相应字段名称。 为此我们创建相应的结构来实现所有这些功能:

//--- Structures of signal properties description for each type
struct STR_SIGNAL_BASE_DOUBLE
  {
   string                     name;
   ENUM_SIGNAL_BASE_DOUBLE    id;
  };
struct STR_SIGNAL_BASE_INTEGER
  {
   string                     name;
   ENUM_SIGNAL_BASE_INTEGER   id;
  };
struct STR_SIGNAL_BASE_DATETIME
  {
   string                     name;
   ENUM_SIGNAL_BASE_INTEGER   id;
  };
struct STR_SIGNAL_BASE_STRING
  {
   string                     name;
   ENUM_SIGNAL_BASE_STRING    id;
  };

接下来,声明结构数组(以下是 ENUM_SIGNAL_BASE_DOUBLE 的示例,其他类型相似):

const STR_SIGNAL_BASE_DOUBLE tab_signal_base_double[]=
  {
     {"Balance",    SIGNAL_BASE_BALANCE},
     {"Equity",     SIGNAL_BASE_EQUITY},
     {"Gain",       SIGNAL_BASE_GAIN},
     {"Drawdown",   SIGNAL_BASE_MAX_DRAWDOWN},
     {"Price",      SIGNAL_BASE_PRICE},
     {"ROI",        SIGNAL_BASE_ROI}
  };
现在,为了接收选定的信号属性值,我们只需在四重循环里运行:
   //--- Read signal properties
   void              Read(void)
     {
      for(int i=0; i<6; i++)
         props_double[i] = SignalBaseGetDouble(ENUM_SIGNAL_BASE_DOUBLE(tab_signal_base_double[i].id));
      for(int i=0; i<7; i++)
         props_int[i] = SignalBaseGetInteger(ENUM_SIGNAL_BASE_INTEGER(tab_signal_base_integer[i].id));
      for(int i=0; i<3; i++)
         props_datetime[i] = datetime(SignalBaseGetInteger(ENUM_SIGNAL_BASE_INTEGER(tab_signal_base_datetime[i].id)));
      for(int i=0; i<5; i++)
         props_str[i] = SignalBaseGetString(ENUM_SIGNAL_BASE_STRING(tab_signal_base_string[i].id));
     }

在上面的示例中,Read()SignalProperties 结构内带的一个方法,能处理您所需的全部信号属性。 这些是每种类型的缓冲区,以及读取当前值并与前值进行比较的方法:

//--- Structure for working with signal properties
struct SignalProperties
  {
   //--- Property buffers
   double            props_double[6];
   long              props_int[7];
   datetime          props_datetime[3];
   string            props_str[5];
   //--- Read signal properties
   void              Read(void)
     {
      for(int i=0; i<6; i++)
         props_double[i] = SignalBaseGetDouble(ENUM_SIGNAL_BASE_DOUBLE(tab_signal_base_double[i].id));
      for(int i=0; i<7; i++)
         props_int[i] = SignalBaseGetInteger(ENUM_SIGNAL_BASE_INTEGER(tab_signal_base_integer[i].id));
      for(int i=0; i<3; i++)
         props_datetime[i] = datetime(SignalBaseGetInteger(ENUM_SIGNAL_BASE_INTEGER(tab_signal_base_datetime[i].id)));
      for(int i=0; i<5; i++)
         props_str[i] = SignalBaseGetString(ENUM_SIGNAL_BASE_STRING(tab_signal_base_string[i].id));
     }
   //--- Compare signal Id with the passed value
   bool              CompareId(long id)
     {
      if(id==props_int[0])
         return true;
      else
         return false;
     }
   //--- Compare signal property values with the ones passed via the link
   bool              Compare(SignalProperties &sig)
     {
      for(int i=0; i<6; i++)
        {
         if(props_double[i]!=sig.props_double[i])
            return false;
        }
      for(int i=0; i<7; i++)
        {
         if(props_int[i]!=sig.props_int[i])
            return false;
        }
      for(int i=0; i<3; i++)
        {
         if(props_datetime[i]!=sig.props_datetime[i])
            return false;
        }
      return true;
     }
   //--- Compare signal property values with the one located inside the passed buffer (search by Id)
   bool              Compare(SignalProperties &buf[])
     {
      int n = ArraySize(buf);
      for(int i=0; i<n; i++)
        {
         if(props_int[0]==buf[i].props_int[0])  // Id
            return Compare(buf[i]);
        }
      return false;
     }
  };


添加到数据库

若要操控数据库,我们首先需要声明 CMySQLTransaction 类的实例:

//--- Include MySQL transaction class
#include  <MySQL\MySQLTransaction.mqh>
CMySQLTransaction mysqlt;

接着,在 OnStart() 函数中设置连接参数。 为此,调用 Config 方法:

//+------------------------------------------------------------------+
//| Service program start function                                   |
//+------------------------------------------------------------------+
void OnStart()
  {
//--- Configure MySQL transaction class
   mysqlt.Config(inp_server,inp_port,inp_login,inp_password);
   
   ...
  }

下一步是创建数据表名称。 由于信号收集取决于经纪商和交易账户类型,因此会用到这些参数来实现此目的。 在服务器名称中,用下划线替换句点、连字符和空格,添加帐户登录名,并将所有字母替换为小写字母。 例如,经纪商服务器为 MetaQuotes-Demo,登录帐户为 17273508 的情况下,数据表名称为 metaquotes_demo__17273508

代码如下所示:

//--- Assign a name to the table
//--- to do this, get the trade server name
   string s = AccountInfoString(ACCOUNT_SERVER);
//--- replace the space, period and hyphen with underscores
   string ss[]= {" ",".","-"};
   for(int i=0; i<3; i++)
      StringReplace(s,ss[i],"_");
//--- assemble the table name using the server name and the trading account login
   string tab_name = s+"__"+IntegerToString(AccountInfoInteger(ACCOUNT_LOGIN));
//--- set all letters to lowercase
   StringToLower(tab_name);
//--- display the result in the console
   Print("Table name: ",tab_name);

接着,将最后的入场数据读取到数据库。 这样做是为了在重新启动服务时,能够将新获得的属性值与前值进行比较,从而检测差异。

DB_Read() 函数从数据库读取属性。

//+------------------------------------------------------------------+
//| Read signal properties from the database                         |
//+------------------------------------------------------------------+
bool  DB_Read(SignalProperties &sbuf[],string tab_name)
  {
//--- prepare a query
   string q="select * from `"+inp_db+"`.`"+tab_name+"` "+
            "where `TimeInsert`= ("+
            "select `TimeInsert` "+
            "from `"+inp_db+"`.`"+tab_name+"` order by `TimeInsert` desc limit 1)";
//--- send a query
   if(mysqlt.Query(q)==false)
      return false;
//--- if the query is successful, get the pointer to it
   CMySQLResponse *r = mysqlt.Response();
   if(CheckPointer(r)==POINTER_INVALID)
      return false;
//--- read the number of rows in the accepted response
   uint rows = r.Rows();
//--- prepare the array
   if(ArrayResize(sbuf,rows)!=rows)
      return false;
//--- read property values to the array
   for(uint n=0; n<rows; n++)
     {
      //--- read the pointer to the current row
      CMySQLRow *row = r.Row(n);
      if(CheckPointer(row)==POINTER_INVALID)
         return false;
      for(int i=0; i<6; i++)
        {
         if(row.Double(tab_signal_base_double[i].name,sbuf[n].props_double[i])==false)
            return false;
        }
      for(int i=0; i<7; i++)
        {
         if(row.Long(tab_signal_base_integer[i].name,sbuf[n].props_int[i])==false)
            return false;
        }
      for(int i=0; i<3; i++)
         sbuf[n].props_datetime[i] = MySQLToDatetime(row[tab_signal_base_datetime[i].name]);
      for(int i=0; i<5; i++)
         sbuf[n].props_str[i] = row[tab_signal_base_string[i].name];
     }
   return true;
  }
函数参数是我们在初始化期间形成的信号缓冲区和数据表名的引用。 在函数主体中我们要做的第一件事是准备一条查询。 在此情况下,我们需要取添加到数据库的时间最长的信号,并读取其所有属性。 由于我们同时写入所有值的数组,因此我们需要在数据表中查找时间最长的信号,并读取该信号的所有值。 例如,如果数据库名称为 signals_mt5,而数据表名称为 metaquotes_demo__17273508,则查询语句如下所示:
select * 
from `signals_mt5`.`metaquotes_demo__17273508`
where `TimeInsert`= (
        select `TimeInsert`
        from `signals_mt5`.`metaquotes_demo__17273508` 
        order by `TimeInsert` desc limit 1)

返回最大 “TimeInsert” 列值(即数据库最后一笔添加时间)的子查询以红色高亮显示。 以绿色高亮显示的查询语句返回所有字符串,其中 `TimeInsert` 值与所找到记录匹配

如果业务成功,则开始读取得到的数据。 为此,获取指向 CMySQLResponse 服务器响应类的指针,然后获取响应中的数据行数。 根据该参数,更改信号缓冲区的大小

现在我们要读取属性。 为此,利用索引接收指向当前数据行的指针。 这之后,读取每种属性类型的值。 例如,若要读取 ENUM_SIGNAL_BASE_DOUBLE 属性,调用 CMySQLRow::Double() 方法,其中第一个参数(字段名称)是属性文本名称。

我们研究一种情况,即发送查询语句但以错误结束。 为此,回到 OnStart() 函数源代码。
//--- Declare the buffer of signal properties
   SignalProperties sbuf[];
//--- Raise the data from the database to the buffer
   bool exit = false;
   if(DB_Read(sbuf,tab_name)==false)
     {
      //--- if the reading function returns an error,
      //--- the table is possibly missing
      if(mysqlt.GetServerError().code==ER_NO_SUCH_TABLE && inp_creating==true)
        {
         //--- if we need to create a table and this is allowed in the settings
         if(DB_CteateTable(tab_name)==false)
            exit=true;
        }
      else
         exit=true;
     }

若发生错误,首先,检查其原因并非由数据表缺失导致。 如果该错误由数据表尚未创建、删除或重命名引发。 ER_NO_SUCH_TABLE 错误表示应创建数据表(如果允许)。

数据表创建函数 DB_CteateTable() 非常简单:

//+------------------------------------------------------------------+
//| Create the table                                                 |
//+------------------------------------------------------------------+
bool  DB_CteateTable(string name)
  {
//--- prepare a query
   string q="CREATE TABLE `"+inp_db+"`.`"+name+"` ("+
            "`PKey`                        BIGINT(20)   NOT NULL AUTO_INCREMENT,"+
            "`TimeInsert`     DATETIME    NOT NULL,"+
            "`Id`             INT(11)     NOT NULL,"+
            "`Name`           CHAR(50)    NOT NULL,"+
            "`AuthorLogin`    CHAR(50)    NOT NULL,"+
            "`Broker`         CHAR(50)    NOT NULL,"+
            "`BrokerServer`   CHAR(50)    NOT NULL,"+
            "`Balance`        DOUBLE      NOT NULL,"+
            "`Equity`         DOUBLE      NOT NULL,"+
            "`Gain`           DOUBLE      NOT NULL,"+
            "`Drawdown`       DOUBLE      NOT NULL,"+
            "`Price`          DOUBLE      NOT NULL,"+
            "`ROI`            DOUBLE      NOT NULL,"+
            "`Leverage`       INT(11)     NOT NULL,"+
            "`Pips`           INT(11)     NOT NULL,"+
            "`Rating`         INT(11)     NOT NULL,"+
            "`Subscribers`    INT(11)     NOT NULL,"+
            "`Trades`         INT(11)     NOT NULL,"+
            "`TradeMode`      INT(11)     NOT NULL,"+
            "`Published`      DATETIME    NOT NULL,"+
            "`Started`        DATETIME    NOT NULL,"+
            "`Updated`        DATETIME    NOT NULL,"+
            "`Currency`       CHAR(50)    NOT NULL,"+
            "PRIMARY KEY (`PKey`),"+
            "UNIQUE INDEX `TimeInsert_Id` (`TimeInsert`, `Id`),"+
            "INDEX `TimeInsert` (`TimeInsert`),"+
            "INDEX `Currency` (`Currency`, `TimeInsert`),"+
            "INDEX `Broker` (`Broker`, `TimeInsert`),"+
            "INDEX `AuthorLogin` (`AuthorLogin`, `TimeInsert`),"+
            "INDEX `Id` (`Id`, `TimeInsert`)"+
            ") COLLATE='utf8_general_ci' "+
            "ENGINE=InnoDB "+
            "ROW_FORMAT=DYNAMIC";
//--- send a query
   if(mysqlt.Query(q)==false)
      return false;
   return true;
  }
该查询本身在字段名称中含有添加数据时的时间 `TimeInsert`,即信号属性的名称。 这是接收属性更新时的本地终端时间。 此外,`TimeInsert``Id` 字段还有唯一的键,以及加速查询执行所必需的索引。

如果数据表创建失败,则显示错误描述,并终止服务。

   if(exit==true)
     {
      if(GetLastError()==(ERR_USER_ERROR_FIRST+MYSQL_ERR_SERVER_ERROR))
        {
         // in case of a server error
         Print("MySQL Server Error: ",mysqlt.GetServerError().code," (",mysqlt.GetServerError().message,")");
        }
      else
        {
         if(GetLastError()>=ERR_USER_ERROR_FIRST)
            Print("Transaction Error: ",EnumToString(ENUM_TRANSACTION_ERROR(GetLastError()-ERR_USER_ERROR_FIRST)));
         else
            Print("Error: ",GetLastError());
        }
      return;
     }

我们可以有三类错误。

  • MySQL 服务器返回的错误(无数据表,无数据库,无效的登录名或密码)
  • 运行时错误(无效的主机,连接错误)
  • ENUM_TRANSACTION_ERROR 错误

错误类型定义了其描述是如何形成的。 错误定义如下。

如果业务无错通过,我们进入主程序循环:

//--- set the time label of the previous reading of signal properties
   datetime chk_ts = 0;

   ...
//--- Main loop of the service operation
   do
     {
      if((TimeLocal()-chk_ts)<inp_period)
        {
         Sleep(1000);
         continue;
        }
      //--- it is time to read signal properties
      chk_ts = TimeLocal();

      ...

     }
   while(!IsStopped());

我们的服务位于这个无限循环中,直到 卸载为止。 读取属性,与前值进行比较,并以指定的周期完成数据库的写操作(如有必要)。

假设我们收到的信号属性与前值不同。 接下来发生以下情况:

      if(newdata==true)
        {
         bool bypass = false;
         if(DB_Write(buf,tab_name,chk_ts)==false)
           {
            //--- if we need to create a table and this is allowed in the settings
            if(mysqlt.GetServerError().code==ER_NO_SUCH_TABLE && inp_creating==true)
              {
               if(DB_CteateTable(tab_name)==true)
                 {
                  //--- if the table is created successfully, send the data
                  if(DB_Write(buf,tab_name,chk_ts)==false)
                     bypass = true; // sending failed
                 }
               else
                  bypass = true; // failed to create the table
              }
            else
               bypass = true; // there is no table and it is not allowed to create one
           }
         if(bypass==true)
           {
            if(GetLastError()==(ERR_USER_ERROR_FIRST+MYSQL_ERR_SERVER_ERROR))
              {
               // in case of a server error
               PrintNotify("MySQL Server Error: "+IntegerToString(mysqlt.GetServerError().code)+" ("+mysqlt.GetServerError().message+")");
              }
            else
              {
               if(GetLastError()>=ERR_USER_ERROR_FIRST)
                  PrintNotify("Transaction Error: "+EnumToString(ENUM_TRANSACTION_ERROR(GetLastError()-ERR_USER_ERROR_FIRST)));
               else
                  PrintNotify("Error: "+IntegerToString(GetLastError()));
              }
            continue;
           }
        }
      else
         continue;

此处,我们可以看到熟悉的代码片段,其中包含检查数据表是否缺失,及随后的创建。 如此在服务运行时,可以由第三方正确处理数据表的删除。 另外,请注意,Print() 被替换为 PrintNotify()。 如果输入允许,此函数复制控制台中显示的字符串作为通知:

//+------------------------------------------------------------------+
//| Print to console and send notification                           |
//+------------------------------------------------------------------+
void PrintNotify(string text)
  {
//--- display in the console
   Print(text);
//--- send a notification
   if(inp_notifications==true)
     {
      static datetime ts = 0;       // last notification sending time
      static string prev_text = ""; // last notification text
      if(text!=prev_text || (text==prev_text && (TimeLocal()-ts)>=(3600*6)))
        {
         // identical notifications are sent one after another no more than once every 6 hours
         if(SendNotification(text)==true)
           {
            ts = TimeLocal();
            prev_text = text;
           }
        }
     }
  }

当检测到属性更新时,调用数据库写入函数:

//+------------------------------------------------------------------+
//| Write signal properties to the database                          |
//+------------------------------------------------------------------+
bool  DB_Write(SignalProperties &sbuf[],string tab_name,datetime tc)
  {
//--- prepare a query
   string q = "insert ignore into `"+inp_db+"`.`"+tab_name+"` (";
   q+= "`TimeInsert`";
   for(int i=0; i<6; i++)
      q+= ",`"+tab_signal_base_double[i].name+"`";
   for(int i=0; i<7; i++)
      q+= ",`"+tab_signal_base_integer[i].name+"`";
   for(int i=0; i<3; i++)
      q+= ",`"+tab_signal_base_datetime[i].name+"`";
   for(int i=0; i<5; i++)
      q+= ",`"+tab_signal_base_string[i].name+"`";
   q+= ") values ";
   int sz = ArraySize(sbuf);
   for(int s=0; s<sz; s++)
     {
      q+=(s==0)?"(":",(";
      q+= "'"+DatetimeToMySQL(tc)+"'";
      for(int i=0; i<6; i++)
         q+= ",'"+DoubleToString(sbuf[s].props_double[i],4)+"'";
      for(int i=0; i<7; i++)
         q+= ",'"+IntegerToString(sbuf[s].props_int[i])+"'";
      for(int i=0; i<3; i++)
         q+= ",'"+DatetimeToMySQL(sbuf[s].props_datetime[i])+"'";
      for(int i=0; i<5; i++)
         q+= ",'"+sbuf[s].props_str[i]+"'";
      q+=")";
     }
//--- send a query
   if(mysqlt.Query(q)==false)
      return false;
//--- if the query is successful, get the pointer to it
   CMySQLResponse *r = mysqlt.Response(0);
   if(CheckPointer(r)==POINTER_INVALID)
      return false;
//--- the Ok type packet should be received as a response featuring the number of affected rows; display it
   if(r.Type()==MYSQL_RESPONSE_OK)
      Print("Added ",r.AffectedRows()," entries");
//
   return true;
  }

传统上,函数代码以查询格式开始。 由于事实上我们要把同类型的属性分解到数组,因此获取字段和值的列表要在循环中执行,因而在代码中看起来非常紧凑。

发送查询后,我们期望服务器反馈 “OK” 类型的响应。 从该响应中,我们调用 AffectedRows() 方法获得有效的数据行数量。 该数字将显示在控制台中。 若发生故障,该函数将返回 false,并在控制台中显示错误消息,若设置中允许的情况下,会作为通知发送。 所获属性不会复制到主缓冲区。 在指定的周期之后,若检测到它们的值有新变化,则尝试将其写入数据库。

信号属性收集服务

图例 1. 启动信号属性收集服务

图例 1 如同在导航器窗口中看到的那样,显示 signals_to_db 服务已启动。 不要忘记选择如上所述的“信号”选项卡,否则该服务将不会接收新数据。


查看属性动态的应用程序

在上一节中,我们实现了若检测到信号属性值变化,则将其添加到数据库中的服务。 下一步是准备一个应用程序,它会在指定的时间间隔内将选定的属性动态以图型显示。 还可以采用某些属性值的筛选器,从而只选择我们感兴趣的信号。

采用 Anatoli Kazharski 开发的创建图形界面的 EasyAndFastGUI 图形库作为应用程序高级 GU 的基础。

查看应用程序

a)

信号的网页

b)

图例 2. 程序的用户界面:所选信号的净值动态图(a); 信号网页上的相同图形(b)

图例 2a 程序的用户界面外观。 左侧部分包含日期范围,而右侧部分包含所选信号的净值属性图。 为了进行比较,图例 2b 提供了信号网页上的净值属性图截图。 略有差异的原因在于,PC 空闲时期会形成数据库“空洞”,以及在终端中更新信号属性值需要相对较长的时间。


设定任务

因此,应用程序具有以下功能:

  • 从数据表中选择数据时,它可以:
    • 设定日期范围
    • 按 SIGNAL_BASE_CURRENCY、SIGNAL_BASE_AUTHOR_LOGIN 和 SIGNAL_BASE_BROKER 属性值设置条件
    • 为 SIGNAL_BASE_EQUITY、SIGNAL_BASE_GAIN、SIGNAL_BASE_MAX_DRAWDOWN 和 SIGNAL_BASE_SUBSCRIBERS 属性设置有效值范围
  • 选择 SIGNAL_BASE_ID值时,构造指定属性的图型


实现

在所有图形元素中,我们需要一个由两个日历组成的模块来设置 “from(从)”和 “to(到)”日期,一组组合框用于从列表中选择值,而输入字段模块则用于编辑极端属性值(如果设定了范围)。 若要禁用条件,选择位于列表开头的 “All” 键值。 另外,为输入字段模块配备一个复选框,该复选框默认情况下处于禁用状态。

应始终指定日期范围。 其他所有内容都可以根据需要进行自定义。 图例 2a 显示在字符串属性模块中严格设置的货币和经纪商,而信号作者的名称未限定(All)。

每个组合框列表均由处理查询时获得的数据形成。 输入字段的极值也是如此。 形成信号 ID 列表,并从中选择某些元素后,将发送数据查询指令,从而绘制指定属性的图型。

若要获得有关程序如何与 MySQL 服务器交互的更多信息,在状态栏中显示已接收和已发送字节的计数器,以及最后一笔业务的时间(图例 2)。 如果一笔业务失败,则显示错误代码(图例 3)。


在状态栏中显示错误

图例 3. 在状态栏中显示错误代码,在“智能系统”选项卡中显示消息

由于大多数服务器的错误文本描述都不适合在进度栏显示,因此要在“智能系统”选项卡里显示它们。

鉴于当前文章与图形无关,故于此我将不讨论用户界面的实现。 作者在系列文章中详细介绍了如何利用该图形库操作。 我以示例为基础,修改了一些文件,即:

      • MainWindow.mqh — 建立图形界面
      • Program.mqh — 与图形界面交互
      • Main.mqh — 数据库操控(已添加)


      多条查询

      运行该程序时用到的数据库查询可以大致分为三组:

      • 获取组合框列表值的查询
      • 获取输入字段模块极值的查询
      • 构建图形的数据查询

      在后两种情况下,单条 SELECT 查询就足够了,而前一种情况则需要为每个列表发送一条单独的查询。 在某些时候,我们无法增加获取数据的时间。 理想情况下,所有值都应同时更新。 另外,不可能仅更新列表的一部分。 为此,采用多条查询。 即使一笔业务(包括处理和传输)延迟了,也仅当收到所有服务器的响应之后才会更新界面。 发生错误时,将禁用界面图形元素列表的部分更新。

      下面是启动程序时立即发送多条查询的示例。

      select `Currency` from `signals_mt5`.`metaquotes_demo__17273508` 
      where `TimeInsert`>='2019-11-01 00:00:00' AND `TimeInsert`<='2019-12-15 23:59:59'  
      group by `Currency`; 
      select `Broker` from `signals_mt5`.`metaquotes_demo__17273508` 
      where `TimeInsert`>='2019-11-01 00:00:00' AND `TimeInsert`<='2019-12-15 23:59:59'  
      group by `Broker`; 
      select `AuthorLogin` from `signals_mt5`.`metaquotes_demo__17273508` 
      where `TimeInsert`>='2019-11-01 00:00:00' AND `TimeInsert`<='2019-12-15 23:59:59'  
      group by `AuthorLogin`; 
      select `Id` from `signals_mt5`.`metaquotes_demo__17273508` 
      where `TimeInsert`>='2019-11-01 00:00:00' AND `TimeInsert`<='2019-12-15 23:59:59'  
      group by `Id`; 
      select  Min(`Equity`) as EquityMin,             Max(`Equity`) as EquityMax, 
              Min(`Gain`) as GainMin,                 Max(`Gain`) as GainMax, 
              Min(`Drawdown`) as DrawdownMin,         Max(`Drawdown`) as DrawdownMax, 
              Min(`Subscribers`) as SubscribersMin,   Max(`Subscribers`) as SubscribersMax 
      from `signals_mt5`.`metaquotes_demo__17273508` 
      where `TimeInsert`>='2019-11-01 00:00:00' AND `TimeInsert`<='2019-12-15 23:59:59'
      

      如我们所见,这是由五条 “SELECT” 查询构成的序列,每条之间以 “;” 分隔。 前四条在指定的时间间隔内,按指定属性(Currency,Broker,AuthorLogin 和 Id)请求唯一值列表。 第五条查询旨在接收来自同一时间间隔的四个属性(Equity, Gain, Drawdown 和 Subscribers)的最小最大值。

      如果我们查看与 MySQL 服务器的数据交换,我们可看到:查询(1)是在单个 TCP 数据包中发送的,而对查询(2)的响应是在不同的 TCP 数据包中传递的(见图例 4)。

      流量分析器中的多条查询

      图例 4. 流量分析器中的多条查询

      请注意,如果嵌套的 “SELECT” 查询之一导致错误,则后续的查询将不予处理。 换言之,MySQL 服务器会逐个处理查询,直至遇到第一个错误


      筛选器

      为了更加便洁,我们添加筛选器,从而减少信号列表,仅保留满足所定义需求的信号。 例如,我们对含有某种基准货币,指定的增长范围,或订户数量非零的信号感兴趣。 为此,在查询中利用 WHERE 运算符:

      select `Broker` from `signals_mt5`.`metaquotes_demo__17273508` 
      where `TimeInsert`>='2019-11-01 00:00:00' AND `TimeInsert`<='2019-12-15 23:59:59' AND `Currency`='USD' AND `Gain`>='100' AND `Gain`<='1399'  
      group by `Broker`; 
      select `AuthorLogin` from `signals_mt5`.`metaquotes_demo__17273508` 
      where `TimeInsert`>='2019-11-01 00:00:00' AND `TimeInsert`<='2019-12-15 23:59:59' AND `Currency`='USD' AND `Gain`>='100' AND `Gain`<='1399'  
      group by `AuthorLogin`; 
      select `Id` from `signals_mt5`.`metaquotes_demo__17273508` 
      where `TimeInsert`>='2019-11-01 00:00:00' AND `TimeInsert`<='2019-12-15 23:59:59' AND `Currency`='USD' AND `Gain`>='100' AND `Gain`<='1399'  
      group by `Id`; 
      select  Min(`Equity`) as EquityMin,             Max(`Equity`) as EquityMax, 
              Min(`Gain`) as GainMin,                 Max(`Gain`) as GainMax, 
              Min(`Drawdown`) as DrawdownMin,         Max(`Drawdown`) as DrawdownMax, 
              Min(`Subscribers`) as SubscribersMin,   Max(`Subscribers`) as SubscribersMax 
      from `signals_mt5`.`metaquotes_demo__17273508` 
      where `TimeInsert`>='2019-11-01 00:00:00' AND `TimeInsert`<='2019-12-15 23:59:59' AND `Currency`='USD' AND `Gain`>='100' AND `Gain`<='1399' 
      

      上面的查询采用输入字段所提供的极值,以基准货币是 USD,而增长值在 100-1399 范围,获取组合框列表。 在此,我们首先要注意查询语句缺了从 Currency 列表中取值。 这是合乎逻辑的,因为我们在组合框列表中选择特定值时会排除所有值。 同时,即便用其作为条件,查询输入字段值也会始终执行。 如此做是为了让用户看到实际的值范围。 假设我们引入了最小增长值 100。 于是,考虑到满足所选条件的数据集,最接近的最小值为 135。 这意味着在收到服务器响应后,值 100 将被替换为 135。

      按指定筛选器查询后,信号 ID 组合框值的列表将大大减少。 可以选择一个信号,并在图形上跟踪其属性的变化。


      保持活跃的持续连接模式

      如果仔细查看图例 4,我们可以看到那里没有连接关闭。 原因是查看信号属性动态的程序采用了我们在此要研究的持续连接模式。

      在开发数据收集服务时,我们禁用了“池逊连接”参数。 记录的数据很少,因此保持连接毫无意义。 此处情况则并非如此。 假设用户正在按照某个属性的动态图来寻找合适的信号。 修改任何受控元素时,查询结果每次都会发送到数据库。 在此情况下,每次都建立和关闭连接是完全错误的。

      若要达成持续连接模式,其超时设置应等于 60 秒

         if(CheckPointer(mysqlt)==POINTER_INVALID)
           {
            mysqlt = new CMySQLTransaction;
            mysqlt.Config(m_mysql_server,m_mysql_port,m_mysql_login,m_mysql_password,60000);
            mysqlt.PingPeriod(10000);
           }
      

      这意味着,如果用户闲置超过 60 秒,则连接将关闭。

      我们看看实际情况。 假设用户更改了某个参数,且在随后一分钟里保持空闲状态。 捕获网络数据包如下所示:

      池逊连接模式下的 ping

      图例 5. 在“保持活跃”模式下捕获数据包

      该图显示了查询(1),含有一串周期 10 秒的 ping(2)且查询(1)过后一分钟连接关闭。 如果用户继续进行操作,且发送的查询频率超过每分钟一次,则连接不会关闭。

      在指定业务类参数的同时,ping 周期也等于 10 秒。 为什么我们需要它? 首先,服务器端不能根据其配置的超时参数关闭连接,必须利用以下查询获得超时值:

      show variables 
              where `Variable_name`='interactive_timeout'
      

      通常是 3600 秒。 理论上,发送 ping 的间隔小于服务器的超时参数就足够了,如此可防止连接从服务器端被关闭。 但是在这种情况下,我们只在发送下一条查询时才会知道连接已丢失。 相反,当设置为 10 秒时,我们几乎可以立即知道连接丢失。


      数据提取

      我们以 GetData 方法的实现为例,看看服务器对多条查询的响应。 该方法旨在更新下拉列表的内容,输入字段的极值,以及所选属性的动态图。
      void CMain::GetData(void)
        {
         if(CheckPointer(mysqlt)==POINTER_INVALID)
           {
            mysqlt = new CMySQLTransaction;
            mysqlt.Config(m_mysql_server,m_mysql_port,m_mysql_login,m_mysql_password,60000);
            mysqlt.PingPeriod(10000);
           }
      //--- Save signal id
         string signal_id = SignalId();
         if(signal_id=="Select...")
            signal_id="";
      //--- Make a query
         string   q = "";
         if(Currency()=="All")
           {
            q+= "select `Currency` from `"+m_mysql_db+"`.`"+m_mysql_table+"` where "+Condition()+" group by `Currency`; ";
           }
         if(Broker()=="All")
           {
            q+= "select `Broker` from `"+m_mysql_db+"`.`"+m_mysql_table+"` where "+Condition()+" group by `Broker`; ";
           }
         if(Author()=="All")
           {
            q+= "select `AuthorLogin` from `"+m_mysql_db+"`.`"+m_mysql_table+"` where "+Condition()+" group by `AuthorLogin`; ";
           }
         q+= "select `Id` from `"+m_mysql_db+"`.`"+m_mysql_table+"` where "+Condition()+" group by `Id`; ";
         q+= "select Min(`Equity`) as EquityMin, Max(`Equity`) as EquityMax";
         q+= ", Min(`Gain`) as GainMin, Max(`Gain`) as GainMax";
         q+= ", Min(`Drawdown`) as DrawdownMin, Max(`Drawdown`) as DrawdownMax";
         q+= ", Min(`Subscribers`) as SubscribersMin, Max(`Subscribers`) as SubscribersMax from `"+m_mysql_db+"`.`"+m_mysql_table+"` where "+Condition();
      //--- Display the transaction result in the status bar
         if(UpdateStatusBar(mysqlt.Query(q))==false)
            return;
      //--- Set accepted values in the combo box lists and extreme values of the input fields
         uint responses = mysqlt.Responses();
         for(uint j=0; j<responses; j++)
           {
            if(mysqlt.Response(j).Fields()<1)
               continue;
            if(UpdateComboBox(m_currency,mysqlt.Response(j),"Currency")==true)
               continue;
            if(UpdateComboBox(m_broker,mysqlt.Response(j),"Broker")==true)
               continue;
            if(UpdateComboBox(m_author,mysqlt.Response(j),"AuthorLogin")==true)
               continue;
            if(UpdateComboBox(m_signal_id,mysqlt.Response(j),"Id",signal_id)==true)
               continue;
            //
            UpdateTextEditRange(m_equity_from,m_equity_to,mysqlt.Response(j),"Equity");
            UpdateTextEditRange(m_gain_from,m_gain_to,mysqlt.Response(j),"Gain");
            UpdateTextEditRange(m_drawdown_from,m_drawdown_to,mysqlt.Response(j),"Drawdown");
            UpdateTextEditRange(m_subscribers_from,m_subscribers_to,mysqlt.Response(j),"Subscribers");
           }
         GetSeries();
        }
      

      首先,形成一条查询。 关于组合框列表,查询中仅包含列表的当前选择值 All条件是在单独的 Condition() 方法中汇总的:

      string CMain::Condition(void)
        {
      //--- Add the time interval
         string s = "`TimeInsert`>='"+time_from(TimeFrom())+"' AND `TimeInsert`<='"+time_to(TimeTo())+"' ";
      //--- Add the remaining conditions if required
      //--- For drop-down lists, the current value should not be equal to All
         if(Currency()!="All")
            s+= "AND `Currency`='"+Currency()+"' ";
         if(Broker()!="All")
           {
            string broker = Broker();
            //--- the names of some brokers contain characters that should be escaped
            StringReplace(broker,"'","\\'");
            s+= "AND `Broker`='"+broker+"' ";
           }
         if(Author()!="All")
            s+= "AND `AuthorLogin`='"+Author()+"' ";
      //--- A checkbox should be set for input fields
         if(m_equity_from.IsPressed()==true)
            s+= "AND `Equity`>='"+m_equity_from.GetValue()+"' AND `Equity`<='"+m_equity_to.GetValue()+"' ";
         if(m_gain_from.IsPressed()==true)
            s+= "AND `Gain`>='"+m_gain_from.GetValue()+"' AND `Gain`<='"+m_gain_to.GetValue()+"' ";
         if(m_drawdown_from.IsPressed()==true)
            s+= "AND `Drawdown`>='"+m_drawdown_from.GetValue()+"' AND `Drawdown`<='"+m_drawdown_to.GetValue()+"' ";
         if(m_subscribers_from.IsPressed()==true)
            s+= "AND `Subscribers`>='"+m_subscribers_from.GetValue()+"' AND `Subscribers`<='"+m_subscribers_to.GetValue()+"' ";
         return s;
        }
      

      如果业务成功,则得到我们随后在循环中要分析的响应数量。

      UpdateComboBox() 方法旨在更新组合框中的数据。 它接收一个指向响应和相应字段名称的指针。 如果响应中存在该字段,则数据将包含在组合框列表中,并且该方法返回 trueset_value 参数包含用户在查询过程中从上一个列表中选择的值。 它应该能在新列表里找到,并设置为当前索引。 如果新列表中没有指定的值,则设置为索引 1 对应的值(“Select ...” 之后)。

      bool CMain::UpdateComboBox(CComboBox &object, CMySQLResponse *p, string name, string set_value="")
        {
         int col_idx = p.Field(name);
         if(col_idx<0)
            return false;
         uint total = p.Rows()+1;
         if(total!=object.GetListViewPointer().ItemsTotal())
           {
            string tmp = object.GetListViewPointer().GetValue(0);
            object.GetListViewPointer().Clear();
            object.ItemsTotal(total);
            object.SetValue(0,tmp);
            object.GetListViewPointer().YSize(18*((total>16)?16:total)+3);
           }
         uint set_val_idx = 0;
         for(uint i=1; i<total; i++)
           {
            string value = p.Value(i-1,col_idx);
            object.SetValue(i,value);
            if(set_value!="" && value==set_value)
               set_val_idx = i;
           }
      //--- if there is no specified value, but there are others, select the topmost one
         if(set_value!="" && set_val_idx==0 && total>1)
            set_val_idx=1;
      //---
         ComboSelectItem(object,set_val_idx);
      //---
         return true;
        }
      

      UpdateTextEditRange() 方法更新文本输入字段的极值。

      bool CMain::UpdateTextEditRange(CTextEdit &obj_from,CTextEdit &obj_to, CMySQLResponse *p, string name)
        {
         if(p.Rows()<1)
            return false;
         else
            return SetTextEditRange(obj_from,obj_to,p.Value(0,name+"Min"),p.Value(0,name+"Max"));
        }
      

      在退出 GetData() 之前,调用 GetSeries()方法,该方法按信号 ID 和属性名称选择数据:

      void CMain::GetSeries(void)
        {
         if(SignalId()=="Select...")
           {
            // if a signal is not selected
            ArrayFree(x_buf);
            ArrayFree(y_buf);
            UpdateSeries();
            return;
           }
         if(CheckPointer(mysqlt)==POINTER_INVALID)
           {
            mysqlt = new CMySQLTransaction;
            mysqlt.Config(m_mysql_server,m_mysql_port,m_mysql_login,m_mysql_password,60000);
            mysqlt.PingPeriod(10000);
           }
         string   q = "select `"+Parameter()+"` ";
         q+= "from `"+m_mysql_db+"`.`"+m_mysql_table+"` ";
         q+= "where `TimeInsert`>='"+time_from(TimeFrom())+"' AND `TimeInsert`<='"+time_to(TimeTo())+"' ";
         q+= "AND `Id`='"+SignalId()+"' order by `TimeInsert` asc";
      
      //--- Send a query
         if(UpdateStatusBar(mysqlt.Query(q))==false)
            return;
      //--- Check the number of responses
         if(mysqlt.Responses()<1)
            return;
         CMySQLResponse *r = mysqlt.Response(0);
         uint rows = r.Rows();
         if(rows<1)
            return;
      //--- copy the column to the graph data buffer (false - do not check the types)
         if(r.ColumnToArray(Parameter(),y_buf,false)<1)
            return;
      //--- form X axis labels
         if(ArrayResize(x_buf,rows)!=rows)
            return;
         for(uint i=0; i<rows; i++)
            x_buf[i] = i;
      //--- Update the graph
         UpdateSeries();
        }
      

      通常,它的实现类似于上面讨论的 GetData() 方法。 但有两件事需要注意:

      • 如果未选择信号(组合框的值等于 Select..."),则图形将被清除,且不会有任何反应。
      • 调用 ColumnToArray() 方法

      所提到的方法是专为复制数据到缓冲区的情况而设计的。 在当前情况下,类型验证被禁用,因为列数据既可是整数类型,亦或实数类型。 在两种情况下,都应将它们复制到 “double” 缓冲区。

      更改任何图形元素时,调用 GetData()GetSeries() 方法:

      //+------------------------------------------------------------------+
      //| Handler of the value change event in the "Broker" combo box      |
      //+------------------------------------------------------------------+
      void CMain::OnChangeBroker(void)
        {
         m_duration=0;
         GetData();
        }
      
      ...
      
      //+------------------------------------------------------------------+
      //| Handler of the value change event in the "SignalID" combo box    |
      //+------------------------------------------------------------------+
      void CMain::OnChangeSignalId(void)
        {
         m_duration=0;
         GetSeries();
        }
      

      上方显示的是经纪人信号 ID 组合框处理程序的源代码。 其余的以类似的方式实现。 选择另一个经纪商时,调用 GetData() 方法,而 GetSeries() 会被依次调用。 选择另一个信号时,立即调用 GetSeries()

      m_duration变量中,累计处理所有查询(包括传输)的总时间,然后显示在状态栏中。 查询执行时间是一个重要参数。 此值上升表明数据库优化中存在瑕疵。

      运行中的应用程序如图例 6 所示。

      运行中的应用程序

      图例 6. 运行中的查看信号属性动态的程序



      结束语

      在本文中,我们研究了利用先前研究过的 MySQL 连通器的示例。 在任务实现时,我们发现在对数据库的频繁查询期间,采用持续连接是最合理的解决方案。 我们还强调了 ping 命令对于防止服务器端关闭连接的重要性。

      至于网络函数,在不借助动态库的帮助下操控 MySQL 只是其可以实现的一小部分。 我们生活在网络技术的时代,Socket 函数组的添加无疑是 MQL5 语言开发中的重要里程碑。

      附件文档内容:

      • Services\signals_to_db.mq5 — 数据收集服务的源代码
      • Experts\signals_from_db\ — 查看信号属性动态的程序源代码
      • Include\MySQL\ — MySQL 连通器源代码
      • Include\EasyAndFastGUI\创建图形界面的图形库(截至文章发布之日)


      本文译自 MetaQuotes Software Corp. 撰写的俄文原文
      原文地址: https://www.mql5.com/ru/articles/7495

      附加的文件 |
      MQL5.zip (355.92 KB)
      轻松快捷开发 MetaTrader 程序的函数库(第 三十四部分):延后交易请求 - 在特定条件下删除和修改订单与持仓 轻松快捷开发 MetaTrader 程序的函数库(第 三十四部分):延后交易请求 - 在特定条件下删除和修改订单与持仓

      在本文中,我们将完成延后请求交易概念的论述,并创建删除挂单,以及在特定条件下修改挂单和持仓的功能。 由此,我们将拥有完整的功能,令我们能够开发简单的自定义策略,或者根据用户定义的条件激活 EA 行为逻辑。

      应用网络函数,或无需 DLL 的 MySQL:第 I 部分 - 连通器 应用网络函数,或无需 DLL 的 MySQL:第 I 部分 - 连通器

      MetaTrader 5 最近已获增网络函数。 这为程序员开发市场所需产品提供了巨大的机遇。 如今,他们能够实现以前需要动态库支持的功能。 在本文中,我们将以 MySQL 为例研究所有的实现。

      监视多币种的交易信号(第二部分):应用程序可视部分的实现 监视多币种的交易信号(第二部分):应用程序可视部分的实现

      在上一篇文章中,我们已创建了应用程序框架,其可作为进一步操作的基础。 在这一部分中,我们将继续开发:创建应用程序的可视部分,并配置界面元素的基本交互。

      预测时间序列(第 1 部分):经验分解模式(EMD)方法 预测时间序列(第 1 部分):经验分解模式(EMD)方法

      本文探讨运用经验分解模式(EMD)预测时间序列的理论和实际应用。 它提议以 MQL 实现此方法,并出示了测试指标和智能交易系统。