ネットワーク関数の適用、または DLL を使用しない MySQL: パートII - シグナル特性の変化を監視するプログラム

18 5月 2020, 10:13
Serhii Shevchuk
1
479

目次

イントロダクション

前のパートでは、MySQLコネクタの実装を検討しました。 今度は、そのアプリケーションの例を検討します。 最も簡単でわかりやすいのは、シグナルプロパティ値のコレクションで、さらに変更を表示する関数です。 ターミナルでは100以上のシグナルが利用でき、各シグナルには20以上のパラメータがあります。 これは、十分なデータを持つことを意味します。 実装された例は、ユーザーがシグナルのWebページに表示されないプロパティの変化を観察する必要がある場合、実用的な意味を持ちます。 変更には、レバレッジ、評価、購読者数などが含まれます。

データを収集するために、シグナルプロパティを定期的にリクエストし、以前の値と比較して、差分が検出された場合に備えて配列全体をデータベースに送信するサービスを実装しましょう。

プロパティダイナミクスを表示するには、選択したプロパティの変更をチャートとして表示するEAを記述します。 また、条件付きデータベースクエリを使用して、プロパティの値でシグナルを並べ替える関数を実装します。

実装の間に、KeepAlive定数接続モードと複数のクエリを使用することが望ましい理由が見つかります。


データ収集サービス

サービスの目的は次のとおりです。

  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サーバーの使用中に発生したエラーに関する通知を送信する権限


シグナルプロパティ値の取得

サービスが正しく機能するためには、ターミナルでシグナルプロパティ値がいつ更新されるのか知ることが重要です。 次の2つのケースで発生します。
  • 起動中。
  • ターミナルの操作中に定期的に、[ツールボックス]ウィンドウの[シグナル]タブがアクティブになっています。。 データ更新周期は3時間です。

少なくとも、これは書き込みの時点でターミナルバージョンで起こることです。

シグナルのプロパティは、次の4つのタイプがあります。

ENUM_SIGNAL_BASE_DATETIME型は、文字列に変換する必要があるENUM_SIGNAL_BASE_INTEGERプロパティを整数ではなく時間として検出するために作成されます。

便宜上、同じ型のプロパティの列挙値を配列(4つのプロパティ型-4つの配列)で分解します。 各列挙体には、データベーステーブルの適切なフィールドの名前でもあるプロパティのテキストの説明が伴います。 これを実現するために適切な構造を作成しましょう。

//--- 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}
  };
ここで、選択したシグナルプロパティの値を受け取るために、4つのループに沿って移動するだけで済みます。
   //--- 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サーバー応答クラスへのポインタを取得し、応答内の行数を取得します。 そのパラメータに応じて、シグナルバッファサイズを変更します。

次に、プロパティを読み取る必要があります。 これを行うには、indexを使用して現在の行へのポインタを受け取ります。。 その後、各プロパティの種類の値を読み取ります。 たとえば、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;
     }

3種類のエラーが発生する可能性があります。

  • 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サービスを表示します。 上記のように[シグナル]タブを選択することを忘れないでください、そうでなければ、サービスは新しいデータを受け取りません。


プロパティダイナミクスを表示するためのアプリケーション

前のセクションでは、変更が検出されたときに、データベースにシグナルプロパティの値を追加するサービスを実装しました。 次のステップでは、選択したプロパティダイナミクスを、指定した時間間隔内にチャートとして表示するためのアプリケーションを準備します。 また、特定のプロパティ値のフィルタを使用して、関心のあるシグナルのみを選択することもできます。

このアプリケーションは、高度なGUIがあります。そのため、Anatoli Kazharskiによるグラフィカルインターフェイスを作成するためのEasyAndFastGUIライブラリを基礎として使用します。

アプリケーションの表示

a)

シグナルのウェブページ

b)

図2. このプログラムのユーザーインターフェイス:選択したシグナルのエクイティダイナミクスチャート(a);シグナルのウェブページ上の同じチャート(b)

図2aは、プログラムのユーザーインターフェイスの外観です。 左側には日付範囲が含まれ、右側の部分には選択したシグナルのEquityプロパティのチャートがあります。 比較のため、図2bはEquityプロパティチャートを使用してシグナルのウェブページのスクリーンショットを特徴とします。 不一致の理由は、PCのアイドル時間の間に形成されたデータベース"holes"と、ターミナルのシグナル特性の値を更新する時間にあります。


タスクの設定

したがって、アプリケーションに次の関数を使用できるようにします。

  • テーブルからデータを選択する場合、次の操作を実行できます。
    • 日付範囲の設定
    • 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"日付を設定するには、2つのカレンダーのブロック、リストから値を選択するためのコンボボックスのグループ、およびインプットフィールドのブロックが必要です。 この条件を無効にするには、先頭に位置するリストに"All"キーの値を使用します。 また、デフォルトでは無効になっています。チェックボックスをインプットフィールドブロックに装備します。

日付範囲は常に指定する必要があります。 他のすべては必要に応じてカスタマイズすることができます。 図2aは、通貨とブローカーが文字列プロパティブロックに厳密に設定されています。対し、シグナル作成者の名前は規制されていない(All)ことを示します。

各コンボボックスリストは、クエリを処理するときに取得したデータを使用して形成されます。 インプットフィールドの極値にも当てはまります。 シグナルIDのリストを形成し、その要素の一部を選択した後、指定されたプロパティのチャートをプロットするためのデータのクエリが送信されます。

プログラムがMySQLサーバーとどのようにデータのやり取りするかの詳細を表示するには、受け入れられたバイトと送信されたバイトのカウンタと、直近のトランザクション時間(図2)をステータスバーに表示します。 トランザクションが失敗した場合は、エラーコードを表示します。図3)。


ステータスバーにエラーを表示する

図3. ステータスバーにエラーコードを表示し、[エキスパート]タブにメッセージを表示する

サーバーエラーのテキスト説明のほとんどはプログレスバーに収まらないため、[エキスパート]タブに表示します。

現在の記事はグラフィックスとは関係ないので、ここではユーザーインターフェイスの実装に関しては考えません。 ライブラリの操作については、一連の記事で著者によって詳細に説明されています。 exampleのファイルに変更を加えました、するわち、基礎として取りあげました:

      • MainWindow.mqhーフェイスの構築
      • Program.mqh—グラフィカルインタフェースとのデータのやり取り
      • Main.mqh—データベースの操作(追加)


      複数のクエリ

      プログラムの実行時に使用するデータベースクエリは、大きく次の3つのグループに分けることができます。

      • コンボボックスのリスト値を取得するためのクエリ
      • インプットフィールドブロックの極値を取得するためのクエリ
      • チャートを作成するためのデータのクエリ

      後者の2つの場合では、1つの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"クエリを";"で区切った5つのシーケンスです。 最初の4つは、指定された時間間隔で指定されたプロパティ(Currency, Broker, AuthorLogin, Id)の一意の値のリストをリクエストします。 5番目のクエリは、同じ時間間隔から4つのプロパティ(Equity, Gain, Drawdown, Subscribers)の最小値最大値を受け取るように設計されています。

      MySQLサーバーとのデータ交換を見ると、クエリ(1)が単一のTCPパケットで送信され、(2)に対する応答が異なるTCPパケットで送信されたことがわかります。)(図4参照)。

      トラフィックアナライザ内の複数のクエリ

      図4. トラフィックアナライザ内の複数のクエリ

      ネストされた"SELECT"クエリの1つがエラーを引き起こす場合、サブシークエントのクエリは処理されないことに注意してください。 つまり、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に置き換えられることを意味します。

      指定したフィルタを使用してクエリを実行すると、SignalIDコンボボックスの値のリストが大幅に削減されます。 シグナルを選択し、チャート上でそのプロパティの変化を追跡することができます。


      キープアライブの定数接続モード

      図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秒以上アイドル状態のままの場合、接続は閉じられます。

      実際にどのように見えるかを見てみましょう。 あるユーザーが、その後1分間アイドル状態のままある特定のパラメータを変更したとします。 ネットワークパケットのキャプチャは次のようになります。

      一定接続モードでのping

      図5. キープアライブモードでの動作時のパケットのキャプチャ

      この画像は、クエリ(1)を示し、10秒(2)の期間を持つpingシリーズを示し、クエリの1分後に接続を閉じます(3)。 ユーザーがタスクを続行し、1分に1回以上クエリが送信された場合、接続は閉じられていない可能性があります。

      トランザクション・クラス・パラメータの指定にも、10秒に等しいping期間が伴いました。 なぜをこれが必要なのでしょうか。 まず、次のクエリを使用してタイムアウト値を取得できる場合は、設定で設定されたタイムアウトに従ってサーバーが接続をその側から閉じないようにする必要があります。

      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()は、コンボボックスのデータを更新するために設計されています。 応答へのポインタと対応するフィールド名を受け取ります。 応答にフィールドが存在する場合、コンボボックスの一覧にデータが含まれ、メソッドはtrueを返します。 set_value引数には、クエリ中にユーザーが選択した前のリストの値が含まれます。 新しいリストに含まれ、現在のリストとして設定されます。 指定した値が新しいリストに存在しない場合は、インデックス1の下の値が設定されます。

      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()メソッドに似ています。 しかし、注意を払う価値のある2つのことがあります。

      • シグナルが選択されていない場合(コンボボックスの値が"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();
        }
      

      BrokerシグナルIDコンボボックスハンドラのソースコードが上に表示されます。 残りの値は、同様の方法で実装されます。 別のブローカーを選択すると、GetData()メソッドが呼び出され、GetSeries()が呼び出されます。 別のシグナルを選択すると、GetSeries()が呼び出されます。

      m_duration変数では、転送を含むすべての照会の処理の合計時間が累積され、ステータスバーに表示されます。 クエリの実行時間は重要なパラメータです。 その値の上昇は、データベースの最適化におけるエラーを示します。

      動作中のアプリケーションは、図6に表示されます。

      アプリケーションの動作

      図6. 動作中のシグナル特性ダイナミクスを表示するためのプログラム



      結論

      この記事では、以前に考慮したMySQLコネクタを適用する例を考察しました。 タスクを実装する際に、データベースへの頻繁なクエリの間に定数接続を使用することが最も合理的な解決策であることを発見しました。 また、サーバー側からの接続終了を防止するためのpingの重要性も強調しました。

      ネットワーク関数に関しては、MySQLを使用することは、ダイナミックライブラリに頼ることなく、その助けを借りて実装できる部分のほんの一部に過ぎません。 我々はネットワーク技術の進化の時に生きています。ソケットの関数グループの追加は間違いなく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)
      最後のコメント | ディスカッションに行く (1)
      MetaTraderプログラムを簡単かつ迅速に開発するためのライブラリ(第29部): 未決取引リクエスト - 特定の条件下での注文とポジションの削除と変更 MetaTraderプログラムを簡単かつ迅速に開発するためのライブラリ(第29部): 未決取引リクエスト - 特定の条件下での注文とポジションの削除と変更

      本稿では、保留中リクエスト取引の概念の説明を完了し、未決注文を削除する機能と、特定の条件下で注文とポジションを変更する機能を作成します。したがって、シンプルなカスタム戦略、またはユーザ定義の条件でアクティブ化されるEA動作ロジックを開発できるようになります。

      ネットワーク関数の適用、または.dll を使用しない MySQL: パート I - コネクタ ネットワーク関数の適用、または.dll を使用しない MySQL: パート I - コネクタ

      MetaTrader5は最近ネットワーク機能を実装しました。 これはMarket向けのプロダクトを開発するプログラマに大きな可能性を提示しました。 これにより、以前は動的ライブラリが必要なものを実装できるようになりました。 この記事では、MySQL の実装を例として使用することを検討します。

      トレードシグナルの多通貨監視(パート2):アプリケーションのビジュアル部分の実装 トレードシグナルの多通貨監視(パート2):アプリケーションのビジュアル部分の実装

      前回の記事では、アプリケーションフレームワークを作成し、以降のすべてのタスクの基礎としました。 このパートでは、開発工程を進めます: アプリケーションのビジュアル部分を作成し、インターフェイス要素の基本的な相互作用を構成します。

      新しいMetaTrader 5 とMQL5の登場 新しいMetaTrader 5 とMQL5の登場

      これはMetaTrader5のただの簡単なレビューです。このような短い時間でシステムのすべての特徴を述べることはできません。2009年9月9日に試用を開始しました。これはシンボル的な日付でラッキーナンバーになると信じています。ベータ版のMetaTrader5とMQL5を手に入れて数日が経ちました。まだ全ての特徴を使ってはいませんが、すでに関心させられました。