見出し画像

ML-EA(機械学習型EA)のバックテスト用ツール(MT5 ×Python)

以前の記事では、ML-EAという機械学習を取り入れたFX自動売買のベースモデルを作成し解説しました。

このEAは、機械学習(Machine Learning)を備えたEAということで以下の通りMT5(MQL5)内では完結せず、外部のPythonスクリプトを使用しています。

ML-EAの処理の流れ

従って、MT5の通常のバックテスト機能では上手くバックテストができません。そこで今回の記事では、ML-EA専用のバックテストツールを開発する方法をご紹介します。


FX自動売買ロジックの概要

改めて、ML-EAのベースモデルのロジック概要は以下の通りです。

  • 機械学習モデルはLight-GBMモデルを採用

  • 訓練や予測に使う特徴量は、4本の移動平均線(1分足、5分足、15分足、1時間足)のみ

  • 目的変数(y)は、4時間後の価格が0.1%以上上昇していればy=1、4時間後の価格が0.1%以上下落していればy=2、それ以外の場合はy=0とする。つまり、y=0,1,2とする三値分類モデル

  • 過去1,000時間のデータでモデルの訓練を行う。4時間毎にモデルの訓練を繰り返し、常に最新データで訓練されたモデルにアップデート

  • 訓練済みモデルによる予測は1分間隔で行い、予測結果がy=1であれば買い注文、y=2であれば売り注文を行う

MQLや Pythonが行う処理の概要

今回のバックテストツールの構造は以下のように図にしてみると非常にシンプルです。

ML-EA_backtestの処理概要

バックテスト用データは、MQL5でMT5から取得するようにしています。これは、実際に取引を行う際と同じ取得方法にしておいた方が望ましいからです。
まずは1,008時間分のデータを取得して、最初の1,000時間分を訓練用データ、最後の4 時間分を予測用データにします。間の余計な4時間は実際の取引時の状況と合わせるためです。例えば今現在、過去1,000時間分のデータからモデルの訓練を行おうとすると、4時間後の結果を予測するモデルなので4時間後の結果が判明している4時間前以前のデータしか訓練に使えません。その状況を再現しています。

Input(グローバル変数)

MQL5内のグローバル変数として以下を定義します。ベースモデルと同じく、機械学習モデルの訓練を
行う間隔や、予測を行う間隔を比較的自由にカスタマイズ可能な仕様にしています。

//+------------------------------------------------------------------+
//| グローバル変数
//+------------------------------------------------------------------+
input double lot_size = 0.01;//ロット数
input int retraining_interval = 4;//モデルを訓練する間隔(時間)
input int training_period = 1000;//訓練用データの取得期間(時間)
input int position_hold_time = 4; // ポジション保有期間(時間)
input double price_change_target = 0.1; // 価格変動の目標値(%)
input double spread_limit = 35;//許容スプレッド(point)

input string start_date = "2024.02.01"; // 開始日(YYYY.MM.DD)
input string end_date = "2024.02.29"; // 終了日(YYYY.MM.DD)

今回は実際に取引は行わず、バックテスト専用のツールにしますので、不要なパラメータは削除しています。一方で、バックテスト実施期間の開始日と終了日を入力できるようにしました。

MQL5のメイン処理

以下は、MQL5内のOnInit関数およびOnTimer関数のコード全文です。

//+------------------------------------------------------------------+
//| 起動時の処理                                                     
//+------------------------------------------------------------------+
void OnInit()
  {
   // プロセス完了ファイルを作成
   CreateProcessDoneFile();
   
   // ファイル削除バッチファイルを実行
   ShellExecuteW(0, "open", delete_files_bat, "", "", 1);

   // タイマーを設定して、定期的に特定の処理を実行
   EventSetTimer(1); // 1秒ごとにOnTimerが呼ばれる

   // バックテストの開始と終了のバーインデックスを計算
   StartCount = GetBarIndexForTime(start_date)-position_hold_time*60;
   EndCount = GetBarIndexForTime(end_date);
  }

//+------------------------------------------------------------------+
//| 一定時間毎の処理                                    
//+------------------------------------------------------------------+
void OnTimer()
  {
   // StartCountがEndCount未満の場合、バックテスト終了
   if(StartCount < EndCount)
     {
      // タイマーを停止して、定期的な処理を終了
      EventKillTimer(); // タイマー停止
      return;
     }
   // バックテストのメイン処理を実行
   Backtest();
  }

非常にシンプルなのですが、Backtest関数というのは別途定義しています。また、OnTick関数ではなくOnTimer関数を利用することで、ティックの動きに関係なく一定時間毎に処理を実行することが可能です。バックテストなのでティックが動いていない時間帯でも実行可能な仕組みにしておきます。

プロセス完了ファイルを作成

   // プロセス完了ファイルを作成
   CreateProcessDoneFile();

バックテストにおいても、MQL5のタイマー処理(ループ処理)の途中で、Pythonの実行処理を組み込んでいます。Pythonで行うのは機械学習モデルの訓練や、訓練済みモデルによる予測ですので、実行にはそれなりの時間がかかります。MQL5の処理が繰り返される中で、 Pythonの処理が完了していることを確認してから次の処理を行うというプロセスを構築しています。

ファイル削除バッチファイルを実行

   // ファイル削除バッチファイルを実行
   ShellExecuteW(0, "open", delete_files_bat, "", "", 1);

今回のツールでは、バックテスト結果を複数のファイルに出力しています。実行時に、いったん過去に作成されていたファイルを一通り削除してから進める仕組みにしています。

タイマーの設定

   // タイマーの設定
   EventSetTimer(1); // 1秒ごとにOnTimerが呼ばれる

1秒ごとのループ処理の設定になります。MQL5上は1秒ごとにループしますが、プロセス完了ファイルの存在を確認することによって、Pythonの実行が完了しているか確認した上で次のステップに進むようになっています。

バックテスト期間の設定

   // バックテストの開始と終了のバーインデックスを計算
   StartCount = GetBarIndexForTime(start_date)-position_hold_time*60;
   EndCount = GetBarIndexForTime(end_date);

バックテストにおけるモデルの訓練期間について、例えば1,000時間と設定していますが、このツールにおいては厳密には時間単位ではなく1分足のレコード単位で行います。つまり、1,000時間を1分足のレコード60,000個と変換して考えます。これは、60,000分間というわけでもありません。全ての時間帯で1分足が形成されるわけでもないので、欠けている部分が存在します。
ここで取得しているバーインデックスというのが、1分足のレコードの中で何番目を示すかというナンバーを意味します。

ループ処理

void OnTimer()
  {
   // StartCountがEndCount未満の場合、バックテスト終了
   if(StartCount < EndCount)
     {
      // タイマーを停止して、定期的な処理を終了
      EventKillTimer(); // タイマー停止
      return;
     }
   // バックテストのメイン処理を実行
   Backtest();
  }

Backtestという関数でバックテスト処理を行います。StartCountとEndCountという変数を用いてバックテスト期間を調整します。

バックテストロジックの概要

さて、ML-EAのロジックに合わせて、バックテストのロジックも設定する必要があります。

  • 過去1,000時間(1分足で60,000レコード)のデータでモデルの訓練を行う。4時間(1分足で240レコード)毎にモデルの訓練を繰り返す。

  • 訓練済みモデルによる予測は1分足の全てのレコードで行い、予測結果がy=1であれば買い注文、y=2であれば売り注文を行ったと仮定する。ポジションのエントリー価格はその足の終値とする。

  • 4時間後(1分足で240レコード後)の終値でポジションをクローズしたと仮定する。クローズ価格とエントリー価格との差が損益となる。

  • スプレッドは、常に設定した許容スプレッドで一定とする。スプレッドを考慮して目的変数(y)を計算し、損益結果にもスプレッドを考慮する。

バックテスト結果

デフォルト設定でのバックテスト結果をご紹介します。

損益グラフ

まずはグラフです。2024年2月のGOLDの結果です。

20240201-20240229

損益(Net Profit)の単位は、価格差そのものにしています。例えば、エントリー価格が2031.31のBuy注文で、クローズ価格が2035.02であれば、スプレッドは0.35と設定してるので、
 Net Profit = 2035.02 - 2031.31 - 0.35 = 3.36
となります。

取引結果サマリー

そして、取引結果の詳細です。

Buy Win: 2331
Sell Win: 1998
Buy Lose: 1518
Sell Lose: 877
Draw: 20636
Total Win: 4329
Total Lose: 2395
Total Records: 27360
Buy Win Rate: 60.56%
Sell Win Rate: 69.50%
Win Rate: 64.38%
Entry Rate: 24.58%

以上は、勝敗数をカウントしています。

  • 全部で27,360レコードある中で、エントリー率(BuyあるいはSell注文を行った割合)が24.58%です。

  • Buy注文の勝率(y=1と予測して、実際にy=1であった割合)が60.56%、Sell注文の勝率(y=2と予測して、実際にy=2であった割合)が69.50%、全体の勝率が64.38%です。

  • Drawは、y=0と予測し注文を行わなかったものと、y=1あるいはy=2と予測したが結果がy=0だったものが含まれます。前者は損益が発生しませんが、後者は実際に取引を行った想定で損益を計算しています。

Buy Gross Profit (Win): 11613.26
Sell Gross Profit (Win): 12753.09
Buy Gross Profit (Lose): -9439.27
Sell Gross Profit (Lose): -4718.62
Draw Gross Profit: 924.76
Total Gross Profit: 11133.22

Gross Profitというのは、スプレッドを考慮する前の損益です。つまり、4時間後の終値と現在の終値の単純な差です。

Buy Net Profit (Win): 10797.41 (Spread: 815.85)
Sell Net Profit (Win): 12053.79 (Spread: 699.30)
Buy Net Profit (Lose): -9970.57 (Spread: 531.30)
Sell Net Profit (Lose): -5025.57 (Spread: 306.95)
Draw Net Profit: -1848.64 (Spread: 2773.40)
Total Net Profit: 6006.42 (Spread: 5126.80)

そしてNet Profitというのが、スプレッドも考慮した損益です。スプレッドは損益にマイナスですので、それぞれNet Profit = Gross Profit - Spreadとなっています。

スプレッドを小さくした場合

デフォルトで0.35であったスプレッドを0.15に変えた場合のバックテスト結果も出力してみます。

Buy Win: 2668
Sell Win: 2095
Buy Lose: 1787
Sell Lose: 1097
Draw: 19713
Total Win: 4763
Total Lose: 2884
Total Records: 27360
Buy Win Rate: 59.89%
Sell Win Rate: 65.63%
Win Rate: 62.29%
Entry Rate: 27.95%

Buy Gross Profit (Win): 12510.45
Sell Gross Profit (Win): 11745.14
Buy Gross Profit (Lose): -9571.70
Sell Gross Profit (Lose): -5285.13
Draw Gross Profit: 479.65
Total Gross Profit: 9878.41

Buy Net Profit (Win): 12110.25 (Spread: 400.20)
Sell Net Profit (Win): 11430.89 (Spread: 314.25)
Buy Net Profit (Lose): -9839.75 (Spread: 268.05)
Sell Net Profit (Lose): -5449.68 (Spread: 164.55)
Draw Net Profit: -715.25 (Spread: 1194.90)
Total Net Profit: 7536.46 (Spread: 2341.95)

今回のモデルでは、目的変数(y)の設定時にもスプレッドを考慮していますので、機械学習モデルの予測自体にもスプレッドが影響することになります。
結果的には、勝率(Win Rate)とエントリー率(Entry Rate)はそれほど変わっていません。
また、Gorss Profitが少し小さくなっていますが、スプレッドが小さくなっていますので、Net Profitは大きくなる結果となっています。
つまり、スプレッドがより小さい取引所や口座を選択すれば、成績が向上する可能性があるということです。

予測結果に確率も考慮した場合

三値分類問題におけるLightGBMのpredictpredict_probaの違いは以下の通りです:

  • predictメソッドは、各サンプルに対して最も確率が高いクラスのラベル(例えば、0、1、または2)を直接返します。

  • predict_probaメソッドは、各サンプルが各クラス(例えば、クラス0、クラス1、クラス2)に属する確率を表す配列を返します。したがって、各サンプルに対して3つの確率が返され、これらの確率の合計は1になります。

具体的には、予測結果を取得する部分を以下のように変更します。

変更前:

# スケーリングと予測
X_remaining_scaled = scaler.transform(X_remaining)
y_pred = model.predict(X_remaining_scaled)

# 予測結果をデータフレームに追加
df_remaining['y_pred'] = y_pred

変更後:

# スケーリングと予測
X_remaining_scaled = scaler.transform(X_remaining)
y_pred_proba = model.predict_proba(X_remaining_scaled)

# 確率に基づいてy_predを設定
y_pred = []
for proba in y_pred_proba:
    if proba[1] >= 0.6:
        y_pred.append(1)
    elif proba[2] >= 0.6:
        y_pred.append(2)
    else:
        y_pred.append(0)

# 予測結果をデータフレームに追加
df_remaining['y_pred'] = y_pred

これは、y=1やy=2と予測する確率が60%を超える場合のみ予測結果を採用し、それ以外の場合はy=0として取引を行わないということになります。
結果は以下の通りです。

Buy Win: 1138
Sell Win: 1090
Buy Lose: 817
Sell Lose: 453
Draw: 23862
Total Win: 2228
Total Lose: 1270
Total Records: 27360
Buy Win Rate: 58.21%
Sell Win Rate: 70.64%
Win Rate: 63.69%
Entry Rate: 12.79%

Buy Gross Profit (Win): 5263.72
Sell Gross Profit (Win): 5920.91
Buy Gross Profit (Lose): -4903.45
Sell Gross Profit (Lose): -2751.53
Draw Gross Profit: 287.16
Total Gross Profit: 3816.81

Buy Net Profit (Win): 4865.42 (Spread: 398.30)
Sell Net Profit (Win): 5539.41 (Spread: 381.50)
Buy Net Profit (Lose): -5189.40 (Spread: 285.95)
Sell Net Profit (Lose): -2910.08 (Spread: 158.55)
Draw Net Profit: -1473.34 (Spread: 1760.50)
Total Net Profit: 832.01 (Spread: 2984.80)

取引を行う条件を厳しくしたので、エントリー率(Entry Rate)が大きく下がっています。一方で、勝率(Win Rate)はそれほど変わっていません。
その結果、最終損益はデフォルトよりも少し小さくなっています。
これは、必ずしもこの変更が良い結果をもたらすわけではないことを示唆しています。

機械学習の特徴量を増やした場合

以下の記事で紹介しているように、特徴量を増やした場合も試してみます。具体的には、MAだけでなくRSIやMACDも特徴量に加えます。

結果は以下の通りです。

Buy Win: 2819
Sell Win: 2331
Buy Lose: 1540
Sell Lose: 611
Draw: 20059
Total Win: 5150
Total Lose: 2151
Total Records: 27360
Buy Win Rate: 64.67%
Sell Win Rate: 79.23%
Win Rate: 70.54%
Entry Rate: 26.68%

Buy Gross Profit (Win): 15085.59
Sell Gross Profit (Win): 18522.41
Buy Gross Profit (Lose): -7642.29
Sell Gross Profit (Lose): -4948.43
Draw Gross Profit: 991.84
Total Gross Profit: 22009.12

Buy Net Profit (Win): 14098.94 (Spread: 986.65)
Sell Net Profit (Win): 17706.56 (Spread: 815.85)
Buy Net Profit (Lose): -8181.29 (Spread: 539.00)
Sell Net Profit (Lose): -5162.28 (Spread: 213.85)
Draw Net Profit: -1576.11 (Spread: 2567.95)
Total Net Profit: 16885.82 (Spread: 5123.30)

特徴量を増やすことにより精度が上がって、勝率(Win Rate)が改善されているようです。エントリー率(Entry Rate)も少し上昇しています。スプレッドはデフォルトと同じ0.35を採用しているのでそれほど変わっていません。
その結果、Profitが全体的に向上しました。ここからも、特徴量エンジニアリングの効果は出ているように見えます。

損益グラフは以下の通りで、特徴量がMAだけのベースモデルよりも形状が良くなっています。

RSIおよびMACD追加後

ポジションクローズ条件について

ML-EAのベースモデルでは、「CloseBuyPositions」および「CloseSellPositions」という関数を定義して、以下の条件を満たしたポジションのみクローズ注文を行うようにしていました。

  • エントリー注文が成立してから4時間(指定した時間)以上経過している

  • 現在価格と取得価格の差が取得価格の0.1%(指定した値)以上である

つまり、少なくとも4時間はポジションをクローズせず、4時間経過して価格が0.1%以上上昇または下落すればポジションをクローズします。利確だけでなく、ロスカットも行います。ただ、4時間経過しても価格が0.1%以上動いていなければ、0.1%以上動くまではポジションを持ち続ける、というロジックです。

一方で、このバックテスト用ツールにおいては、エントリー注文が成立してから4時間(指定した時間)経過した時点で即時にポジションをクローズする想定で損益計算を行なっています。
したがって、バックテスト用ツールとクローズ条件を合わせるためには以下のように「CloseBuyPositions」および「CloseSellPositions」のロジックを変更する必要があります。

変更前

//+------------------------------------------------------------------+
//| buyポジションをクローズする関数
//+------------------------------------------------------------------+
bool CloseBuyPositions()
  {
   bool result = false;
   datetime current_time = TimeCurrent();
   int time_threshold = position_hold_time * 60 * 60; // 時間を秒単位に変換

   for(int i = 0; i < PositionsTotal(); i++)
     {
      if(PositionSelectByTicket(PositionGetTicket(i)) && PositionGetInteger(POSITION_MAGIC) == magic_number)
        {
         if(PositionGetInteger(POSITION_TYPE) == POSITION_TYPE_BUY)
           {
            datetime position_open_time = (datetime)PositionGetInteger(POSITION_TIME);
            double position_open_price = PositionGetDouble(POSITION_PRICE_OPEN);
            double current_price = SymbolInfoDouble(_Symbol, SYMBOL_BID);
            double price_change_percent = (current_price - position_open_price) / position_open_price * 100;

            if(current_time - position_open_time > time_threshold &&
               (price_change_percent >= price_change_target || price_change_percent <= -price_change_target))
              {
  
  /// 以下省略 ///

変更後

//+------------------------------------------------------------------+
//| buyポジションをクローズする関数
//+------------------------------------------------------------------+
bool CloseBuyPositions()
  {
   bool result = false;

   for(int i = 0; i < PositionsTotal(); i++)
     {
      if(PositionSelectByTicket(PositionGetTicket(i)) && PositionGetInteger(POSITION_MAGIC) == magic_number)
        {
         if(PositionGetInteger(POSITION_TYPE) == POSITION_TYPE_BUY)
           {
            datetime entry_time = (datetime)PositionGetInteger(POSITION_TIME);
            int entry_index = GetBarIndexByTime(entry_time);
            int current_index = 0;

            if(entry_index - current_index >= position_hold_time * 60)
              {
  
  /// 以下省略 ///

価格変動率(price_change_percent)がターゲット以上かどうかの判定をクローズ条件から外しています。また、単純に時間で経過を判定していましたが、1分足(バー)のインデックスで経過を判定するように変更しています。つまり、1分足が形成された数を時間単位と考えて、1レコード=1分と考えています。このバックテスト用ツールでも同じような考え方をしています。

なお、「GetBarIndexByTime」という関数を定義して、時間からバーのインデックスを取得しています。以下の記事でより詳しく解説していますので参考にしてください。

有料部分の内容

以上、ML-EA用バックテストツールで行う処理および結果の概要を説明しました。
有料部分では一連の処理を実行するための全コードを掲載した以下のファイルをダウンロード可能にしています。

  • ML-EA_backtest.mq5(バックテストに必要な全ての関数を掲載)

  • ML-EA_backtest.ex5(上記ファイルのコンパイル後)

  • backtest.py(機械学習の訓練と予測を繰り返しながらバックテストを行うためのPythonスクリプト)

  • profit_summary.py(損益の集計およびグラフ作成を行うためのPythonスクリプト)

  • delete_files.py(バックテスト実施前にファイル削除を行うためのPythonスクリプト)

注意点

  • 当記事のEAはバックテスト専用のEAですので、実際に取引を行う機能は搭載しておりません。

  • 当記事で掲載しているコードはPythonの環境設定含め、必要な準備が整っている上での実行を想定しています。環境設定に問題がある場合はご自身で解決していただかないと実際のプログラム実行まで辿り着けない可能性があります。

  • 記事執筆時点で稼働確認を行なっており、エラーが出ないことを確認しておりますが、その後の環境変化等で想定通りに稼働しない可能性はございます。動作保証等はいたしかねますのでご了承ください。

  • 当記事で解説しているロジック通りの動作を保証するものではございません。あくまでFX自動売買ツール開発のためのサンプルコードとしてご活用ください。

  • 今回作成したのはあくまでML-EAのバックテストを行うためのベースロジックであり、様々なロジックに拡張するための土台に過ぎません。基本的には、読者の皆様ご自身でカスタマイズしてご利用いただくことを想定しています。

ここから先は

10,228字 / 2画像

¥ 3,000

よろしければサポートお願いします。いただいたサポートは今後の記事の執筆に活用させていただきます。