エキスパートアドバイザーのQ値の開発
Ricardo Rodrigues Lucca | 8 1月, 2024
はじめに
この記事では、エキスパートアドバイザー(EA)がストラテジーテスターで表示できる品質スコアを開発する方法を見ていきます。下の図1では、OnTester resultの値が1.0639375であり、実行されたシステムの品質の一例を示していることがわかります。この記事では、システムの品質を測定するために可能な2つのアプローチを学び、どちらか一方の値しか返せないため、両方の値をログに記録する方法について説明します。
図1:OnTester resultフィールドの強調表示
取引モデルの開始とEAの構築
システムQ値に取り組む前に、テストで使用する基本システムを作る必要があります。ランダムな数字を選び、それが偶数なら買いポジションを建て、そうでなければ奇数なので売りポジションを持ちます。
描画を保持するために、MathRand()関数を使用します。この関数は、0から32767の間の数値を提供します。さらに、よりバランスの取れたシステムにするために、2つの補完的なルールを追加します。この3つのルールで、より信頼性の高いシステムを確保しようと考えています。以下です。
- ポジションがないときは、乱数を生成します。
- 数値が0または32767の場合、何もしません。
- 数字が偶数の場合、最小ロットサイズに等しい数量の資産を買います。
- 数字が奇数の場合、最小ロットサイズに等しい数量の資産を売ります。
- ポジションを持ったら、新しいローソク足が前のローソク足より高くなるごとに、ストップレベルを移動させます。
- 使用するストップレベルは、1期間のATR指標を8EMAで正規化したものです。さらに、分析に使われる2本のローソク足から最も遠い端に位置します。
- 時間が11:00から16:00の範囲外の場合、ポジションを建てることはできず、16:30にポジションを決済しなければなりません。
以下は、これらのルールを開発するために使用されたコードです。
//--- Indicator ATR(1) with EMA(8) used for the stop level... int ind_atr = iATR(_Symbol, PERIOD_CURRENT, 1); int ind_ema = iMA(_Symbol, PERIOD_CURRENT, 8, 0, MODE_EMA, ind_atr); //--- Define a variable that indicates that we have a deal... bool tem_tick = false; //--- An auxiliary variable for opening a position #include<Trade/Trade.mqh> #include<Trade/SymbolInfo.mqh> CTrade negocios; CSymbolInfo info; //--- Define in OnInit() the use of the timer every second //--- and start CTrade int OnInit() { //--- Set the fill type to keep a pending order //--- until it is fully filled negocios.SetTypeFilling(ORDER_FILLING_RETURN); //--- Leave the fixed deviation at it is not used on B3 exchange negocios.SetDeviationInPoints(5); //--- Define the symbol in CSymbolInfo... info.Name(_Symbol); //--- Set the timer... EventSetTimer(1); //--- Set the base of the random number to have equal tests... MathSrand(0xDEAD); return(INIT_SUCCEEDED); } //--- Since we set a timer, we need to destroy it in OnDeInit(). void OnDeinit(const int reason) { EventKillTimer(); } //--- The OnTick function only informs us that we have a new deal void OnTick() { tem_tick = true; } //+------------------------------------------------------------------+ //| Expert Advisor main function | //+------------------------------------------------------------------+ void OnTimer() { MqlRates cotacao[]; bool fechar_tudo = false; bool negocios_autorizados = false; //--- Do we have a new trade? if(tem_tick == false) return ; //--- To check, return information of the last 3 candlesticks.... if(CopyRates(_Symbol, PERIOD_CURRENT, 0, 3, cotacao) != 3) return ; //--- Is there a new candlestick since the last check? if(tem_vela_nova(cotacao[2]) == false) return ; //--- Get data from the trade window and closing... negocios_autorizados = esta_na_janela_de_negocios(cotacao[2], fechar_tudo); //--- If we are going to close everything and if there is a position, close it... if(fechar_tudo) { negocios.PositionClose(_Symbol); return ; } //--- if we are not closing everything, move stop level if there is a position... if(arruma_stop_em_posicoes(cotacao)) return ; if (negocios_autorizados == false) // are we outside the trading window? return ; //--- We are in the trading window, try to open a new position! int sorteio = MathRand(); //--- Entry rule 1.1 if(sorteio == 0 || sorteio == 32767) return ; if(MathMod(sorteio, 2) == 0) // Draw rule 1.2 -- even number - Buy { negocios.Buy(info.LotsMin(), _Symbol); } else // Draw rule 1.3 -- odd number - Sell { negocios.Sell(info.LotsMin(), _Symbol); } } //--- Check if we have a new candlestick... bool tem_vela_nova(const MqlRates &rate) { static datetime vela_anterior = 0; datetime vela_atual = rate.time; if(vela_atual != vela_anterior) // is time different from the saved one? { vela_anterior = vela_atual; return true; } return false; } //--- Check if the time is n the trade period to close positions... bool esta_na_janela_de_negocios(const MqlRates &rate, bool &close_positions) { MqlDateTime mdt; bool ret = false; close_positions = true; if(TimeToStruct(rate.time, mdt)) { if(mdt.hour >= 11 && mdt.hour < 16) { ret = true; close_positions = false; } else { if(mdt.hour == 16) close_positions = (mdt.min >= 30); } } return ret; } //--- bool arruma_stop_em_posicoes(const MqlRates &cotacoes[]) { if(PositionsTotal()) // Is there a position? { double offset[1] = { 0 }; if(CopyBuffer(ind_ema, 0, 1, 1, offset) == 1 // EMA successfully copied? && PositionSelect(_Symbol)) // Select the existing position! { ENUM_POSITION_TYPE tipo = (ENUM_POSITION_TYPE) PositionGetInteger(POSITION_TYPE); double SL = PositionGetDouble(POSITION_SL); double TP = info.NormalizePrice(PositionGetDouble(POSITION_TP)); if(tipo == POSITION_TYPE_BUY) { if (cotacoes[1].high > cotacoes[0].high) { double sl = MathMin(cotacoes[0].low, cotacoes[1].low) - offset[0]; info.NormalizePrice(sl); if (sl > SL) { negocios.PositionModify(_Symbol, sl, TP); } } } else // tipo == POSITION_TYPE_SELL { if (cotacoes[1].low < cotacoes[0].low) { double sl = MathMax(cotacoes[0].high, cotacoes[1].high) + offset[0]; info.NormalizePrice(sl); if (SL == 0 || (sl > 0 && sl < SL)) { negocios.PositionModify(_Symbol, sl, TP); } } } } return true; } // there was no position return false; }
上のコードについて簡単に考えてみましょう。ATRから計算された平均値を使用して、前のローソク足を上回るローソク足を見つけたときに、ローソク足の境界線に置くストップのサイズを決定します。これは関数arruma_stop_em_posicoesでおこなわれます。trueが返されるたびに、ポジションがあることになるので、OnTimerのメインコードで前進しないようにします。この関数をOnTickの代わりに使っているのは、取引がおこなわれるたびに大きな関数を実行する必要がないからです。この関数は、定義された期間の新しいローソク足ごとに実行されなければなりません。OnTickでは、trueに設定された値は、前回の取引を示します。そうしないと、市場が閉じている時間帯に、ストラテジーテスターは前の取引がなくても関数を実行するため、一時停止が発生します。
ここまでは、指定された2つの期間を含め、すべてが決められたプランに厳密に従いました。1つ目は、11:00から4:00までの取引開始時間です。2つ目は管理期間で、アルゴリズムが16:30までストップを動かしてオープン取引を管理し、この時点でその日のすべての取引をクローズします。
今、このEAを取引すると、図2で見られるように、「OnTesterの結果」はゼロになることに注意してください。
図2:EAをUSDJPY、H1、OHLCモード1分足で2023-01-01から2023-05-19までの期間に実行
Q値について
OnTester resultの値を表示できるようにするには、double値を返すOnTester関数を定義する必要があります。ここで、以下のコードを使用すると、図3に示す結果が得られます。
図3:2023-01-01から2023-05-19まで、USDJPY、H1、OHLCモード1分足でEAを実行
次のコードは、前のコードの最後に記述します。ここでは、取引の平均的なリスクリターン比率を計算します。リスクは一定であると仮定され、1に等しいため、この比率は通常、受け取ったリターンとして表されます。したがって、リスクリターン比は1:1.23または単に1.23と解釈することができ、別の例としては0.43とすることもできます。最初の例では、1ドルのリスクを冒すごとに1.23ドルの利益を得ますが、2番目の例では、1ドルのリスクを冒すごとに0.43ドルの損失を被ります。したがって、リターンが1かそれに近い場合は収支がプラス、それ以上の場合は勝っていることを意味します。
この統計では平均的な利益額や損失額がわからないため、各サイドの取引量(買いまたは売り)で正規化したグロス値を使用します。約定した取引の値を返して利用する場合は、1が加算されます。こうすることで、利益が出る取引がなかったり、負ける取引がなかったりしても、計算中にゼロで除算されてプログラムが終了することはありません。さらに、図1で5桁以上あったように、桁数が多く表示されるのを避けるため、NormalizeDoubleを使用して、結果を2桁のみで表示するようにしました。
double OnTester() { //--- Average profit double lucro_medio=TesterStatistics(STAT_GROSS_PROFIT)/(TesterStatistics(STAT_PROFIT_TRADES)+1); //--- Average loss double prejuizo_medio=-TesterStatistics(STAT_GROSS_LOSS)/(TesterStatistics(STAT_LOSS_TRADES)+1); //--- Risk calculation: profitability to be returned double rr_medio = lucro_medio / prejuizo_medio; //--- return NormalizeDouble(rr_medio, 2); }
値がレポートに表示されるようにするには、各エキスパートアドバイザーにOnTester関数が存在する必要があります。数行のコードをコピーする作業を最小限にするため、この関数を別のファイルに移すことにします。こうすれば、毎回コピーするのは1行だけになります。これは次のようにおこなわれます。
#include "ARTICLE_METRICS.mq5"
こうすることで簡潔なコードになります。指定されたファイルでは、関数は定義によって定義されます。includeを使いたい場合、includeする関数名を簡単に変更することができ、OnTester関数がすでに定義されている場合に起こりうる重複エラーを避けることができます。つまり、これはEAコードに直接挿入されるOnTesterを優先するためのメカニズムだと考えることができます。includeで使いたい場合は、EAコードのOnTester関数をコメントアウトし、対応するマクロ定義をコメントアウトするだけです。これについては、また後ほど。
最初は、ARTICLE_METRICS.mq5ファイルは次のようになります。
//--- Risk calculation: average return on operation double rr_medio() { //--- Average profit double lucro_medio=TesterStatistics(STAT_GROSS_PROFIT)/(TesterStatistics(STAT_PROFIT_TRADES)+1); //--- Average loss double prejuizo_medio=-TesterStatistics(STAT_GROSS_LOSS)/(TesterStatistics(STAT_LOSS_TRADES)+1); //--- Risk calculation: profitability to be returned double rr_medio = lucro_medio / prejuizo_medio; //--- return NormalizeDouble(rr_medio, 2); } //+------------------------------------------------------------------+ //| OnTester | //+------------------------------------------------------------------+ #ifndef SQN_TESTER_ON_TESTER #define SQN_TESTER_ON_TESTER OnTester #endif double SQN_TESTER_ON_TESTER() { return rr_medio(); }
正しいファイル名には拡張子「mqh」が必要です。ただし、Expertsディレクトリにファイルを保存するつもりなので、あえて拡張子を残しました。これは読者の裁量に任されています。
品質計算の最初のバージョン
品質計算の最初のバージョンは、Sunny Harrisが作成したCPC指数アプローチに基づいています。このアプローチでは、リスク:平均リターン、成功率、利益率という3つの指標を掛け合わせます。ただし、利益係数を使用しないように修正し、代わりに利益係数と回収係数の間で最も低い値を使用します。両者の違いを考えれば、回収係数を選ぶべきですが、すでに計算の改善につながるので、そのままにしておきました。
次のコードは、前の段落で述べたアプローチを実装したものです。OnTesterで呼び出せばいいだけです。提供された値は一般的なものであり、評価するために少なくとも1つの取引があると予想されるため、ここでは取引数に1を加えていないことに注意してください。
//--- Calculating CPC Index by Sunny Harris double CPCIndex() { double taxa_acerto=TesterStatistics(STAT_PROFIT_TRADES)/TesterStatistics(STAT_TRADES); double fator=MathMin(TesterStatistics(STAT_PROFIT_FACTOR), TesterStatistics(STAT_RECOVERY_FACTOR)); return NormalizeDouble(fator * taxa_acerto * rr_medio(), 5); }
品質計算バージョン2
2つ目のQ値は、システムQ値(SQN)と呼ばれるもので、Van Tharpによって作成されました。各月の約定を計算し、シミュレーションの全月の単純平均を求めます。SQNが前節で述べたアプローチと異なるのは、取引システムの安定性を重視しようとする点です。
SQNの重要な特徴は、顕著なスパイクを持つシステムにペナルティを課すことです。したがって、システムが一連の小さな取引と1つの大きな取引を持っている場合、後者はペナルティを受けることになります。つまり、損失が小さく、利益が大きいシステムがあった場合、その利益はペナルティを受けることになります。その逆(利益が小さく、損失が大きい)もペナルティを受けます。後者は取引をする人にとっては最悪です。
取引は長期戦であることを忘れず、常に自分のシステムに従うことに集中してください。月末の稼ぎは変動が大きいからです。
//--- standard deviation of executed trades based on results in money double dp_por_negocio(uint primeiro_negocio, uint ultimo_negocio, double media_dos_resultados, double quantidade_negocios) { ulong ticket=0; double dp=0.0; for(uint i=primeiro_negocio; i < ultimo_negocio; i++) { //--- try to get deals ticket if((ticket=HistoryDealGetTicket(i))>0) { //--- get deals properties double profit=HistoryDealGetDouble(ticket,DEAL_PROFIT); //--- create price object if(profit!=0) { dp += MathPow(profit - media_dos_resultados, 2.0); } } } return MathSqrt(dp / quantidade_negocios); } //--- Calculation of System Quality Number, SQN, by Van Tharp double sqn(uint primeiro_negocio, uint ultimo_negocio, double lucro_acumulado, double quantidade_negocios) { double lucro_medio = lucro_acumulado / quantidade_negocios; double dp = dp_por_negocio(primeiro_negocio, ultimo_negocio, lucro_medio, quantidade_negocios); if(dp == 0.0) { // Because the standard deviation returned a value of zero, which we didn't expect // we change it to average_benefit, since there is no deviation, which // brings the system closer to result 1. dp = lucro_medio; } //--- The number of trades here will be limited to 100, so that the result will not be //--- maximized due to the large number of trades. double res = (lucro_medio / dp) * MathSqrt(MathMin(100, quantidade_negocios)); return NormalizeDouble(res, 2); } //--- returns if a new month is found bool eh_um_novo_mes(datetime timestamp, int &mes_anterior) { MqlDateTime mdt; TimeToStruct(timestamp, mdt); if(mes_anterior < 0) { mes_anterior=mdt.mon; } if(mes_anterior != mdt.mon) { mes_anterior = mdt.mon; return true; } return false; } //--- Monthly SQN double sqn_mes(void) { double sqn_acumulado = 0.0; double lucro_acumulado = 0.0; double quantidade_negocios = 0.0; int sqn_n = 0; int mes = -1; uint primeiro_negocio = 0; uint total_negocios; //--- request the history of trades if(HistorySelect(0,TimeCurrent()) == false) return 0.0; total_negocios = HistoryDealsTotal(); //--- the average for each month is calculated for each trade for(uint i=primeiro_negocio; i < total_negocios; i++) { ulong ticket=0; //--- Select the required ticket to pick up data if((ticket=HistoryDealGetTicket(i))>0) { datetime time = (datetime)HistoryDealGetInteger(ticket, DEAL_TIME); double lucro = HistoryDealGetDouble(ticket,DEAL_PROFIT); if(lucro == 0) { //--- If there is no result, move on to the next trade. continue; } if(eh_um_novo_mes(time, mes)) { //--- If we have trades, then we calculate sqn, otherwise it will be equal to zero... if(quantidade_negocios>0) { sqn_acumulado += sqn(primeiro_negocio, i, lucro_acumulado, quantidade_negocios); } //--- The calculated amount sqns is always updated! sqn_n++; primeiro_negocio=i; lucro_acumulado = 0.0; quantidade_negocios = 0; } lucro_acumulado += lucro; quantidade_negocios++; } } //--- when exiting "for", we can have undesired result if(quantidade_negocios>0) { sqn_acumulado += sqn(primeiro_negocio, total_negocios, lucro_acumulado, quantidade_negocios); sqn_n++; } //--- take the simple average of sqns return NormalizeDouble(sqn_acumulado / sqn_n, 2); }
コードを見てみましょう。
最初の関数は、一連の取引の標準偏差を計算します。ここではVan Tharpの推奨に従い、すべての取引を標準偏差の計算に含めます。ただし、最終的な計算式(以下の関数)では、取引回数を100回に制限しています。これは、取引回数によって結果が歪まないようにするためで、より実用的で意味のあるものになります。
最後にsqn_mes関数があり、これは新しい月かどうかを確認し、上記の関数に必要なデータを蓄積します。この関数の最後に、シミュレーションが実行された期間の月平均SQNが計算されます。この簡単な説明は、コードの概要と各機能の目的を説明するためのものです。このアプローチに従うことで、SQNの計算をよりよく理解することができます。
double SQN_TESTER_ON_TESTER() { PrintFormat("%G,%G,%G", rr_medio(), CPCIndex(), sqn_mes()); return NormalizeDouble(sqn_mes() * CPCIndex(), 5); }
終了する前に
この記事を締めくくる前に、重複ミスを避ける方法を確認するために、includeを使ったトピックに戻りましょう。OnTester関数を持つEAのコードがあり、指定されたファイルをincludeしたいとします。以下のようになります(この例ではOnTesterの内容は無視してください)。
//+------------------------------------------------------------------+ double OnTester() { return __LINE__; } //+------------------------------------------------------------------+ #include "ARTICLE_METRICS.mq5"
このコードでは、関数の重複エラーが発生します。というのも、私たちのコードのEAとインクルードファイルの両方が、同じOnTesterという名前の関数を持っているからです。しかし、2つの定義を使って片方の名前を変更し、どちらの機能を使うべきかを有効/無効にするメカニズムをシミュレートすることができます。下の例をご覧ください。
//+------------------------------------------------------------------+ #define OnTester disable //#define SQN_TESTER_ON_TESTER disable double OnTester() { return __LINE__; } #undef OnTester //+------------------------------------------------------------------+ #include "ARTICLE_METRICS.mq5"
この新しい形式では、定義がEAコード内の関数名をOnTesterからdisableに変更するため、関数の重複エラーは発生しません。最初の定義をコメントアウトし、2番目の定義をコメントアウト解除すると、ARTICLE_METRICSファイル内の関数はdisableに名前変更され、EAファイル内の関数はOnTesterと呼ばれたままになります。
この方法は、数行のコードをコメントアウトすることなく、両方の関数を切り替えるかなり簡単な方法のようです。多少侵襲的であっても、ユーザーにとっては検討の余地があると思います。ユーザーが考慮すべきもう1つの点は、インクルードされているファイルに関数がすでに存在するため、EA内にその関数を保持する必要があるかどうかです。これは混乱を招く可能性があります。
結論
ランダムに動作するEAのモデルを紹介したこの記事もここまでとなりました。Q値の計算を実証するための例として、これを使用しました。Van TharpとSunny Harrisの2つの計算について考えました。また、リスクとリターンの関係を使った入門的な要素も紹介されました。また、「include」を使用することで、使用可能な関数を簡単に切り替えることができることも示しました。
ご質問や誤りを発見された場合は、記事にコメントをお寄せください。EAとメトリックスファイルの両方で議論されたコードは、添付のzipにあります。
別の品質指標をお使いの方は、こちらにコメントしてご共有ください。ご精読、ありがとうございました。