![並列粒子群最適化](https://c.mql5.com/2/49/Parallel-Particle-Swarm-Optimization_600x314.jpg)
並列粒子群最適化
ご存知のように、MetaTrader 5では、組み込みのストラテジーテスターを使用し、入力パラメータの直接列挙と遺伝的アルゴリズム(GA)の2つのアルゴリズムに基づいて取引ストラテジーを最適化できます。遺伝的最適化は、プロセスを大幅に高速化する進化的アルゴリズムの一種です。ただし、GAの結果は、特定のGA実装のタスクと詳細、特にテスターの実装の詳細に大きく依存する可能性があります。標準機能を拡張したい多くのトレーダーがMetaTrader用の独自のオプティマイザーの作成を試みるのはそのためです。ここでは、可能な高速最適化手法は遺伝的アルゴリズムに限定されません。GAに加えて、焼きなまし法やなどの他の一般的な手法があります。
本稿では、粒子群最適化(PSO)アルゴリズムを実装してMetaTraderテスターに統合し、利用可能なローカルエージェントでの並列実行を試みます。最適化目的関数は、ユーザが選択したEAの取引変数になります。
粒子群最適化
アルゴリズムの観点から、PSO手法は比較的単純です。その本旨は、エキスパートアドバイザーの入力パラメータの空間に仮想「粒子」のセットを生成することです。粒子は移動し、空間内の対応する点でのEAの取引指標に応じて速度を変更します。このプロセスは、パフォーマンスの向上が止まるまで何度も繰り返されます。アルゴリズムの擬似コードを以下に示します。
粒子群最適化の擬似コード
このコードによると、各粒子には、現在位置、速度、過去の「最良」点の記憶があります。ここで、「最良」点とは、この粒子の目的関数の最高値が達成された点(EA入力パラメータのセット)を意味します。これをクラスで説明しましょう。
class Particle { public: double position[]; // current point double best[]; // best point known to the particle double velocity[]; // current speed double positionValue; // EA performance in current point double bestValue; // EA performance in the best point int group; Particle(const int params) { ArrayResize(position, params); ArrayResize(best, params); ArrayResize(velocity, params); bestValue = -DBL_MAX; group = -1; } };
すべての配列のサイズは最適化空間の次元に等しいため、最適化されている(コンストラクタに渡される)エキスパートアドバイザーパラメータの数に等しくなります。デフォルトでは、目的関数の値が大きいほど、最適化が向上します。したがって、bestValueフィールドを最小許容値の-DBL_MAXで初期化します。取引指標の1つは通常、利益、収益性、シャープレシオなどのEAを評価するための基準として使用されます。ドローダウンなど、低い値の方が良いと考えられるパラメータによって最適化が実行される場合、反対の値を最大化するために適切な変換を行うことができます。
配列と変数は、アクセスとその再計算コードを簡素化するために公開されています。OOPの原則を厳密に順守するには、「private」修飾子を使用してそれらを非表示にし、読み取りおよび変更メソッドを記述する必要があります。
個々の粒子に加えて、アルゴリズムはいわゆる「トポロジー」または粒子の小集団で動作します。それらは、さまざまな原則に従って作成できます。この場合、「社会集団トポロジー」が使用されます。このようなグループには、すべての粒子の中で最適な位置に関する情報が格納されます。
class Group { private: double result; // best EA performance in the group public: double optimum[]; // best known position in the group Group(const int params) { ArrayResize(optimum, params); ArrayInitialize(optimum, 0); result = -DBL_MAX; } void assign(const double x) { result = x; } double getResult() const { return result; } bool isAssigned() { return result != -DBL_MAX; } };
Particleクラスの「group」フィールドにグループ名を指定することで、粒子が属するグループを示します(上記を参照)。
次に、粒子群アルゴリズム自体のコーディングに移りましょう。これは別のクラスとして実装されます。粒子とグループの配列から始めます。
class Swarm { private: Particle *particles[]; Group *groups[]; int _size; // number of particles int _globals; // number of groups int _params; // number of parameters to optimize
パラメータごとに、最適化が実行される値の範囲と増分(ステップ)を指定する必要があります。
double highs[]; double lows[]; double steps[];
さらに、パラメータの最適なセットをどこかに保存する必要があります。
double solution[];
クラスにはいくつかの異なるコンストラクタがあるので、統合された初期化メソッドについて説明しましょう。
void init(const int size, const int globals, const int params, const double &max[], const double &min[], const double &step[]) { _size = size; _globals = globals; _params = params; ArrayCopy(highs, max); ArrayCopy(lows, min); ArrayCopy(steps, inc); ArrayResize(solution, _params); ArrayResize(particles, _size); for(int i = 0; i < _size; i++) // loop through particles { particles[i] = new Particle(_params); ///do ///{ for(int p = 0; p < _params; p++) // loop through all dimensions { // random placement particles[i].position[p] = (MathRand() * 1.0 / 32767) * (highs[p] - lows[p]) + lows[p]; // adjust it according to step granularity if(steps[p] != 0) { particles[i].position[p] = ((int)MathRound((particles[i].position[p] - lows[p]) / steps[p])) * steps[p] + lows[p]; } // the only position is the best so far particles[i].best[p] = particles[i].position[p]; // random speed particles[i].velocity[p] = (MathRand() * 1.0 / 32767) * 2 * (highs[p] - lows[p]) - (highs[p] - lows[p]); } ///} ///while(index.add(crc64(particles[i].position)) && !IsStopped()); } ArrayResize(groups, _globals); for(int i = 0; i < _globals; i++) { groups[i] = new Group(_params); } for(int i = 0; i < _size; i++) { // random group membership particles[i].group = (_globals > 1) ?(int)MathMin(MathRand() * 1.0 / 32767 * _globals, _globals - 1) : 0; } }
すべての配列は指定された次元に従って分散され、転送されたデータで埋められます。粒子の初期位置、速度、グループメンバーシップは無作為に決定されます。上記のコードでは重要な何かがコメント化されていますが、これには少し後で戻ります。
粒子群アルゴリズムの古典バージョンは、連続座標で定義された関数を最適化することを目的としています。ただし、EAパラメータは通常、特定のステップでテストされます。たとえば、標準の移動平均の期間を11.5にすることはできません。そのため、すべての次元について、許容値の範囲に加えて、粒子の位置を四捨五入するために使用するステップを設定しました。これは、初期化フェーズだけでなく、最適化中の計算でも行われます。
次に、initを使用していくつかのコンストラクタを実装します。
#define AUTO_SIZE_FACTOR 5 public: Swarm(const int params, const double &max[], const double &min[], const double &step[]) { init(params * AUTO_SIZE_FACTOR, (int)MathSqrt(params * AUTO_SIZE_FACTOR), params, max, min, step); } Swarm(const int size, const int globals, const int params, const double &max[], const double &min[], const double &step[]) { init(size, globals, params, max, min, step); }
1つ目は、よく知られている経験則を使用して、パラメータの数に基づいて群のサイズとグループの数を計算します。AUTO_SIZE_FACTOR定数(デフォルトでは5)は、必要に応じて変更できます。2番目のコンストラクタでは、すべての値を明示的に指定できます。
デストラクタは、割り当てられたメモリを解放します。
~Swarm() { for(int i = 0; i < _size; i++) { delete particles[i]; } for(int i = 0; i < _globals; i++) { delete groups[i]; } }
次に、最適化を直接実行するクラスのメインメソッドを記述します。
double optimize(Functor &f, const int cycles, const double inertia = 0.8, const double selfBoost = 0.4, const double groupBoost = 0.4)
最初のパラメータであるFunctor &fは、特に重要です。明らかに、エキスパートアドバイザーは、さまざまな入力パラメータの最適化プロセス中に呼び出され、それに応じて推定数(利益、収益性、または別の特性)が返されます。群はエキスパートアドバイザーについて何も知りません(そして知るべきではありません)。その唯一のタスクは、任意の数値引数のセットで未知の目的関数の最適値を見つけることです。そのため、抽象インターフェイス、つまりFunctorクラスを使用します。
class Functor { public: virtual double calculate(const double &vector[]) = 0; };
唯一のメソッドはパラメータの配列を受け取り、数値を返します(すべてのタイプはdoubleです)。将来的には、EAはFunctorから派生したクラスを何らかの方法で実装し、calculateメソッド内で必要な変数を計算する必要があります。したがって、「optimize」メソッドの最初のパラメータは、自動売買ロボットによって提供されるコールバック関数を持つオブジェクトを受け取ります。
「optimize」メソッドの2番目のパラメータは、アルゴリズムを実行するためのループの最大数です。次の3つのパラメータは、PSO係数を設定します。「inertia」は粒子の速度を維持し(速度は通常1未満の値で減少します)、「selfBoost」および「groupBoost」はそれぞれ、粒子/グループ履歴の位置の最もよく知られている方向に方向を調整するときの粒子の応答性を決定します。
すべてのパラメータを検討したので、アルゴリズムに進むことができます。最適化ループは、疑似コードをほぼ完全に単純化された形式で再現します。
double optimize(Functor &f, const int cycles, const double inertia = 0.8, const double selfBoost = 0.4, const double groupBoost = 0.4) { double result = -DBL_MAX; ArrayInitialize(solution, 0); for(int c = 0; c < cycles && !IsStopped(); c++) // predefined number of cycles { for(int i = 0; i < _size && !IsStopped(); i++) // loop through all particles { for(int p = 0; p < _params; p++) // update particle position and speed { double r1 = MathRand() * 1.0 / 32767; double rg = MathRand() * 1.0 / 32767; particles[i].velocity[p] = inertia * particles[i].velocity[p] + selfBoost * r1 * (particles[i].best[p] - particles[i].position[p]) + groupBoost * rg * (groups[particles[i].group].optimum[p] - particles[i].position[p]); particles[i].position[p] = particles[i].position[p] + particles[i].velocity[p]; // make sure to keep the particle inside the boundaries of parameter space if(particles[i].position[p] < lows[p]) particles[i].position[p] = lows[p]; else if(particles[i].position[p] > highs[p]) particles[i].position[p] = highs[p]; // respect step size if(steps[p] != 0) { particles[i].position[p] = ((int)MathRound((particles[i].position[p] - lows[p]) / steps[p])) * steps[p] + lows[p]; } } // get the function value for the particle i particles[i].positionValue = f.calculate(particles[i].position); // update the particle's best value and position (if improvement is found) if(particles[i].positionValue > particles[i].bestValue) { particles[i].bestValue = particles[i].positionValue; ArrayCopy(particles[i].best, particles[i].position); } // update the group's best value and position (if improvement is found) if(particles[i].positionValue > groups[particles[i].group].getResult()) { groups[particles[i].group].assign(particles[i].positionValue); ArrayCopy(groups[particles[i].group].optimum, particles[i].position); // update the global maximum value and solution (if improvement is found) if(particles[i].positionValue > result) { result = particles[i].positionValue; ArrayCopy(solution, particles[i].position); } } } } return result; }
このメソッドは、目的関数の検出された最大値を返します。別のメソッドは、座標(パラメータセット)を読み取るために予約されています。
bool getSolution(double &result[]) { ArrayCopy(result, solution); return !IsStopped(); }
アルゴリズムは、これはほぼすべてです。ある程度の単純化がなされていることは以前に述べました。まず、次の特定の機能について見ていきます。
繰り返しのない離散な世界
ファンクターは、パラメータセットを動的に再計算するために何度も呼び出されますが、特に軸に沿った離散性を考慮すると、アルゴリズムが同じ点に数回ヒットしない保証はありません。このようなヒットを防ぐためには、計算済みの点を特定し、スキップする必要があります。
パラメータは単なる数字またはバイトのシーケンスです。データの一意性を確認するための最も有名な手法は、ハッシュを使用することです。ハッシュを取得する最も一般的な方法はCRC(巡回冗長検査)です。CRCは、データセットからの2つのそのような特性番号の一致がセットが同一である可能性が高いことを意味するように、データに基づいて生成されたチェック番号(通常は整数、マルチビット)です。CRCのビット数が多いほど、一致する可能性が高くなります(ほぼ100%まで)。ここでのタスクには、おそらく64ビットのCRCで十分ですが、必要に応じて、別のハッシュ関数に拡張または変更できます。CRC計算の実装は、CからMQLに簡単に移植できます。可能なオプションの1つは、以下に添付されているcrc64.mqhファイルで利用できます。主な関数には以下のプロトタイプがあります。
ulong crc64(ulong crc, const uchar &s[], int l);
前のデータブロックからのCRC(複数の場合、または1つのブロックがある場合は0を指定)、バイトの配列、および処理する必要のある要素の数に関する情報を受け入れます。この関数は64ビットのCRCを返します。
この関数に一連のパラメータを入力する必要があります。ただし、各パラメータはdoubleであるため、これを直接使用することはできません。バイト配列に変換するには、TypeToBytes.mqhライブラリを使用します(ファイルは記事に添付されていますが、コードベースで最新バージョンを確認することをお勧めします) 。
このライブラリを組み込んだ後、パラメータの配列からCRC64を計算するラッパー関数を作成できます。
#include <TypeToBytes.mqh> #include <crc64.mqh> template<typename T> ulong crc64(const T &array[]) { ulong crc = 0; int len = ArraySize(array); for(int i = 0; i < len; i++) { crc = crc64(crc, _R(array[i]).Bytes, sizeof(T)); } return crc; }
ここで、ハッシュを保存する場所とその一意性を確認する方法についての質問が発生します。最も適切な解決策は、二分木です。二分木は、新しい値を追加たりすでに追加された値の存在を確認するための迅速な操作を提供するデータ構造体です。二分木は、バランシングと呼ばれる特別な木のプロパティによって高速になります。つまり、操作の最大速度を確保するためには、木のバランスをとる必要があります(常にバランスの取れた状態に保つ必要があります)。ハッシュを格納するために木を使用するのはよいことです。これがハッシュの定義です。
ハッシュ関数(ハッシュ生成アルゴリズム)は、任意の入力データに対して一様分布の出力値を生成します。その結果、二分木にハッシュを追加すると、統計的にバランスの取れた状態になり、高効率になります。
二分木はノード(節点)の集まりであり、各ノードには、特定の値と、いわゆる右ノードと左ノードへの2つのオプションの参照が含まれています。左側のノードの値は、常に親ノードの値よりも小さくなります。右側のノードの値は、常に親の値よりも大きくなります。木は、新しい値をノード値と比較することにより、ルート(根)から塗りつぶしを開始します。新しい値がルート(または他のノード)の値と等しい場合、木に存在する値の符号が返されます。新しい値がノードの値よりも小さい場合は、参照によって左側のノードに移動し、同様の方法でその部分木を処理します。新しい値がノードの値より大きい場合は、右側の部分木に従います。参照のいずれかがnullの場合(それ以上の分岐がないことを意味します)、検索は結果なしで終了します。そのため、null参照ではなく、新しい値を持つ新しいノードを作成する必要があります。
このロジックを実装するために、TreeNodeとBinaryTreeのテンプレートクラスのペアが作成されました。完全なコードは、添付のヘッダファイルにあります。
template<typename T> class TreeNode { private: TreeNode *left; TreeNode *right; T value; public: TreeNode(T t): value(t) {} // adds new value into subtrees and returns false or // returns true if t exists as value of this node or in subtrees bool add(T t); ~TreeNode(); TreeNode *getLeft(void) const; TreeNode *getRight(void) const; T getValue(void) const; }; template<typename T> class BinaryTree { private: TreeNode<T> *root; public: bool add(T t); ~BinaryTree(); };
値が木にすでに存在する場合、「add」メソッドはtrueを返します。以前は存在しなかったが、追加されたばかりの場合はfalseを返します。木のデストラクタでルートを削除すると、すべての子ノードが自動的に削除されます。
実装された木のクラスは、最も単純なバリアントの1つです。他にももっと高度な木があるので、必要に応じて埋め込んでみてください。
SwarmクラスにBinaryTreeを追加しましょう。
class Swarm { private: BinaryTree<ulong> index;
粒子を新しい位置に移動する「optimize」メソッドの部分を拡張する必要があります。
double optimize(Functor &f, const int cycles, const double inertia = 0.8, const double selfBoost = 0.4, const double groupBoost = 0.4) { // ... double next[]; ArrayResize(next, _params); for(int c = 0; c < cycles && !IsStopped(); c++) { int skipped = 0; for(int i = 0; i < _size && !IsStopped(); i++) { // new placement of particles using temporary array next for(int p = 0; p < _params; p++) { double r1 = MathRand() * 1.0 / 32767; double rg = MathRand() * 1.0 / 32767; particles[i].velocity[p] = inertia * particles[i].velocity[p] + selfBoost * r1 * (particles[i].best[p] - particles[i].position[p]) + groupBoost * rg * (groups[particles[i].group].optimum[p] - particles[i].position[p]); next[p] = particles[i].position[p] + particles[i].velocity[p]; if(next[p] < lows[p]) next[p] = lows[p]; else if(next[p] > highs[p]) next[p] = highs[p]; if(steps[p] != 0) { next[p] = ((int)MathRound(next[p] / steps[p])) * steps[p]; } } // check if the tree contains this parameter set and add it if not if(index.Add(crc64(next))) { skipped++; continue; } // apply new position to the particle ArrayCopy(particles[i].position, next); particles[i].positionValue = f.calculate(particles[i].position); // ... } Print("Cycle ", c, " done, skipped ", skipped, " of ", _size, " / ", result); if(skipped == _size) break; // full coverage }
補助配列「next」を追加しました。新しく作成された座標はまずここに追加されます。CRCが計算され、値の一意性が確認されます。新しい位置がまだ検出されていない場合は、木に追加され、対応する粒子にコピーされ、この位置に対して必要なすべての計算が実行されます。位置が木にすでに存在する場合(つまり、ファンクターがすでに計算されている場合)、この反復はスキップされます。
基本機能のテスト
上記のすべては、最初のテストを実行するために最低限必要な基礎です。testpso.mq5スクリプトを使用して、最適化が実際に機能することを確認しましょう。このスクリプトで使用されるParticleSwarmParallel.mqhヘッダファイルには、すでに使い慣れたクラスだけでなく、以下で検討するその他の改善点も含まれています。
テストはOOPスタイルで設計されており、お気に入りの目的関数を設定できます。テストの基本クラスはBaseFunctorです。
class BaseFunctor: public Functor { protected: const int params; double max[], min[], steps[]; public: BaseFunctor(const int p): params(p) // number of parameters { ArrayResize(max, params); ArrayResize(min, params); ArrayResize(steps, params); ArrayInitialize(steps, 0); PSOTests::register(&this); } virtual void test(const int loop) // worker method { Swarm swarm(params, max, min, steps); swarm.optimize(this, loop); double result[]; swarm.getSolution(result); for(int i = 0; i < params; i++) { Print(i, " ", result[i]); } } };
派生クラスのすべてのオブジェクトは、PSOTestsクラスの「register」メソッドを使用して、作成時に自動的に登録されます。
class PSOTests { static BaseFunctor *testCases[]; public: static void register(BaseFunctor *f) { int n = ArraySize(testCases); ArrayResize(testCases, n + 1); testCases[n] = f; } static void run(const int loop = 100) { for(int i = 0; i < ArraySize(testCases); i++) { testCases[i].test(loop); } } };
テスト(最適化)は「run」メソッドによって実行されます。このメソッドは、登録されているすべてのオブジェクトに対して「test」を呼び出します。
一般的なベンチマーク関数には「rosenbrock」、「griewank」、「sphere」など数多くあり、これらはスクリプトに実装されています。たとえば、球の検索範囲と「計算」メソッドは、次のように定義できます。
class Sphere: public BaseFunctor { public: Sphere(): BaseFunctor(3) // expected global minimum (0, 0, 0) { for(int i = 0; i < params; i++) { max[i] = 100; min[i] = -100; } } virtual void test(const int loop) { Print("Optimizing " + typename(this)); BaseFunctor::test(loop); } virtual double calculate(const double &vec[]) { int dim = ArraySize(vec); double sum = 0; for(int i = 0; i < dim; i++) sum += pow(vec[i], 2); return -sum; // negative for maximization } };
標準のベンチマーク関数は最小化を使用しますが、ここでは最大化ベースのアルゴリズムを実装しています(最大のEAパフォーマンスを検索することを目的としているため)。このため、計算結果はマイナス記号で使用されます。また、ここでは離散ステップを使用しないため、関数は連続的です。
void OnStart()
{
PSOTests::Sphere sphere;
PSOTests::Griewank griewank;
PSOTests::Rosenbrock rosenbrock;
PSOTests::run();
}
スクリプトを実行すると、正確な解(極値)に近い座標値がログに記録されることがわかります。粒子は無作為に初期化されるため、実行するたびにわずかに異なる値が生成されます。解の精度は、アルゴリズムの入力パラメータに依存します。
Optimizing PSOTests::Sphere PSO[3] created: 15/3 PSO Processing... Cycle 0 done, skipped 0 of 15 / -1279.167775306995 Cycle 10 done, skipped 0 of 15 / -231.4807406906516 Cycle 20 done, skipped 0 of 15 / -4.269510657558273 Cycle 30 done, skipped 0 of 15 / -1.931949742316357 Cycle 40 done, skipped 0 of 15 / -0.06018744740061506 Cycle 50 done, skipped 0 of 15 / -0.009498109984732127 Cycle 60 done, skipped 0 of 15 / -0.002058433538555499 Cycle 70 done, skipped 0 of 15 / -0.0001494176502579518 Cycle 80 done, skipped 0 of 15 / -4.141817579039349e-05 Cycle 90 done, skipped 0 of 15 / -1.90930142126799e-05 Cycle 99 done, skipped 0 of 15 / -8.161728746514931e-07 PSO Finished 1500 of 1500 planned calculations: true 0 -0.000594423827318461 1 -0.000484001094843528 2 0.000478096358862763 Optimizing PSOTests::Griewank PSO[2] created: 10/3 PSO Processing... Cycle 0 done, skipped 0 of 10 / -26.96927938978973 Cycle 10 done, skipped 0 of 10 / -0.939220906325796 Cycle 20 done, skipped 0 of 10 / -0.3074442362962919 Cycle 30 done, skipped 0 of 10 / -0.121905607345751 Cycle 40 done, skipped 0 of 10 / -0.03294107382891465 Cycle 50 done, skipped 0 of 10 / -0.02138355984774098 Cycle 60 done, skipped 0 of 10 / -0.01060479828529859 Cycle 70 done, skipped 0 of 10 / -0.009728742850384609 Cycle 80 done, skipped 0 of 10 / -0.008640623678293768 Cycle 90 done, skipped 0 of 10 / -0.008578769833161193 Cycle 99 done, skipped 0 of 10 / -0.008578769833161193 PSO Finished 996 of 1000 planned calculations: true 0 3.188612982502877 1 -4.435728146291838 Optimizing PSOTests::Rosenbrock PSO[2] created: 10/3 PSO Processing... Cycle 0 done, skipped 0 of 10 / -19.05855349617553 Cycle 10 done, skipped 1 of 10 / -0.4255148824156119 Cycle 20 done, skipped 0 of 10 / -0.1935391314277153 Cycle 30 done, skipped 0 of 10 / -0.006468452482022688 Cycle 40 done, skipped 0 of 10 / -0.001031992354315317 Cycle 50 done, skipped 0 of 10 / -0.00101322411502283 Cycle 60 done, skipped 0 of 10 / -0.0008800704421316765 Cycle 70 done, skipped 0 of 10 / -0.0005593151578155307 Cycle 80 done, skipped 0 of 10 / -0.0005516786893301249 Cycle 90 done, skipped 0 of 10 / -0.0005473814163781119 Cycle 99 done, skipped 0 of 10 / -7.255520122486163e-06 PSO Finished 982 of 1000 planned calculations: true 0 1.001858172119364 1 1.003524791491219
群のサイズとグループの数(PSO[N] created: X/Gのようにログイン行に書き込まれます。ここでNは空間の次元、Xは粒子の数、Gはグループの数)は、入力データに基づいて、プログラムされた経験則に従って自動的に選択されます。
並行の世界への移行
最初のテストは良いですが、微妙な差異があります。粒子カウントサイクルは1つのスレッドで実行されるが、ターミナルではすべてのプロセッサコアを利用できるということです。ここでの最終的な目標は、MetaTraderテスターでのマルチスレッドの最適化のためにEAに組み込むことができるPSO最適化エンジンを作成し、標準の遺伝的アルゴリズムに代わるものを提供することです。
スクリプトの代わりにEA内でアルゴリズムを機械的に転送することによって計算を並列化することはできません。これには、アルゴリズムの変更が必要です。
既存のコードを見ると、このタスクは、並列計算に粒子のグループを選択することを提案しています。各グループは個別に処理できます。各グループ内で指定された回数だけフルサイクルが実行されます。
「Swarm」クラスコアを変更しないでいいように、単純なソリューションを使用します。クラス内の複数のグループの代わりに、いくつかのクラスインスタンスを作成します。各インスタンスでは、グループの数が縮退します。つまり、1になります。さらに、各インスタンスは独自のテストエージェントで実行されるため、インスタンスが情報を交換できるようにするコードを提供する必要があります。
まず、新しいオブジェクトの初期化方法を追加しましょう。
Swarm(const int size, const int globals, const int params, const double &max[], const double &min[], const double &step[]) { if(MQLInfoInteger(MQL_OPTIMIZATION)) { init(size == 0 ?params * AUTO_SIZE_FACTOR : size, 1, params, max, min, step); } ...
最適化モードでのプログラム操作に応じて、グループ数を1に設定します。デフォルトの群サイズは、経験則によって決定されます(「size」パラメータが0以外の値に明示的に設定されている場合を除く)。
OnTesterイベントハンドラでは、エキスパートアドバイザーは、getSolution関数を使用してミニ群(1つのグループのみで構成される)の結果を取得し、それをフレームでターミナルに送信できます。ターミナルはパスを分析し、最適なパスを選択できます。論理的には、並列の群/グループの数は、少なくともコアの数と等しくなければなりません。ただし、それより高くなる可能性があります(ただし、コア数の倍数にする必要があります)。空間の寸法が大きいほど、より多くのグループが必要になる場合があります。ただし、コアの数は単純なテストには十分なはずです。
重複ポイントのない空間を計算するには、インスタンス間のデータ交換が必要です。ご存知のように、各オブジェクトで処理された点のリストは、「index」二分木に保存されます。結果と同様に、これはフレーム内でターミナルに送信できますが、問題は、これらのリストの仮想的な結合レジストリをテストエージェントに送り返すことができないことです。残念ながら、テスターアーキテクチャは、エージェントからターミナルへの制御されたデータ転送のみをサポートし、その逆はサポートしていません。ターミナルからのタスクは、閉じた形式でエージェントに配布されます。
そのため、ローカルエージェントのみを使用し、各グループのインデックスを共有フォルダー(FILE_COMMON)内のファイルに保存することにしました。各エージェントは独自のインデックスを作成し、他のすべてのパスのインデックスをいつでも読み取ったり、独自のインデックスに追加したりできます。これは、パスの初期化中に必要になる場合があります。
MQLでは、書き込まれたファイルの変更は、ファイルが閉じられている場合にのみ他のプロセスで読み取ることができます。FILE_SHARE_READフラグ、FILE_SHARE_WRITEフラグ、FileFlush関数はここでは役に立ちません。
インデックス作成のサポートは、よく知られている「visitor」パターンを使用して実装されます。
template<typename T> class Visitor { public: virtual void visit(TreeNode<T> *node) = 0; };
その最小限のインターフェイスで、渡された木ノードで任意の操作を実行することを宣言します。ファイルを操作するための特定の後継実装(Exporter)が作成されました。各ノードの内部値は、参照によって木全体を走査する順序で、ファイルの個別の行に格納されます。
template<typename T> class Exporter: public Visitor<T> { private: int file; uint level; public: Exporter(const string name): level(0) { file = FileOpen(name, FILE_READ | FILE_WRITE | FILE_CSV | FILE_ANSI | FILE_SHARE_READ| FILE_SHARE_WRITE | FILE_COMMON, ','); } ~Exporter() { FileClose(file); } virtual void visit(TreeNode<T> *node) override { #ifdef PSO_DEBUG_BINTREE if(node.getLeft()) visit(node.getLeft()); FileWrite(file, node.getValue()); if(node.getRight()) visit(node.getRight()); #else const T v = node.getValue(); FileWrite(file, v); level++; if((level | (uint)v) % 2 == 0) { if(node.getLeft()) visit(node.getLeft()); if(node.getRight()) visit(node.getRight()); } else { if(node.getRight()) visit(node.getRight()); if(node.getLeft()) visit(node.getLeft()); } level--; #endif } };
最も論理的と思われる木の順序付き走査法は、コンテキスト比較のためにファイル内で並べ替えられた行を受信する必要がある場合にのみ、デバッグ目的で使用できます。このメソッドは、PSO_DEBUG_BINTREE条件付きコンパイルディレクティブに囲まれており、デフォルトでは無効になっています。実際には、木の統計的バランスは、木に格納されている無作為で均一に分散された値(ハッシュ)を追加することによって保証されます。木の要素が並び替えされた形式で保存されている場合、ファイルからのアップロードは、最も最適ではなく遅い構成(1つの長い分岐またはリスト)になります。これを回避するために、ノードが処理される順序に関して、木の保存段階で不確実性が導入されます。
渡された訪問者に木を保存する特別なメソッドは、Explorerクラスを使用してBinaryTreeクラスに簡単に追加できます。
template<typename T> class BinaryTree { ... void visit(Visitor<T> *visitor) { visitor.visit(root); } };
操作を実行するには、Swarmクラスの新しいメソッドも必要です。
void exportIndex(const int id) { const string name = sharedName(id); Exporter<ulong> exporter(name); index.visit(&exporter); }
「id」パラメータは、一意のパス番号(グループ番号と同じ)を意味します。このパラメータは、テスターで最適化を構成するために使用されます。exportIndexメソッドは、optimizeとgetSolutionの2つのswarmメソッドの実行直後に呼び出す必要があります。これは、常に必要とは限らないため、呼び出し元のコードによって実行されます。最初の「並列」の例(詳細を参照)では必要ありません。グループの数がコアの数と等しい場合、それらは並行して起動されるため、情報を交換できず、ループ内のファイルの読み取りは効率的ではありません。
exportIndex内に記載されているsharedNameヘルパー関数を使用すると、グループ番号、EA名、およびターミナルフォルダーに基づいて一意の名前を作成できます。
#define PPSO_FILE_PREFIX "PPSO-" string sharedName(const int id, const string prefix = PPSO_FILE_PREFIX, const string ext = ".csv") { ushort array[]; StringToShortArray(TerminalInfoString(TERMINAL_PATH), array); const string program = MQLInfoString(MQL_PROGRAM_NAME) + "-"; if(id != -1) { return prefix + program + StringFormat("%08I64X-%04d", crc64(array), id) + ext; } return prefix + program + StringFormat("%08I64X-*", crc64(array)) + ext; }
-1に等しい識別子が関数に渡されると、関数はこのターミナルインスタンスのすべてのファイルを検索するためのマスクを作成します。この機能は、(このエキスパートアドバイザーの以前の最適化から)古い一時ファイルを削除するとき、および並列ストリームのインデックスを読み取るときに使用されます。これは一般的に以下のように行われます。
bool restoreIndex() { string name; const string filter = sharedName(-1); // use wildcards to merge multiple indices for all cores long h = FileFindFirst(filter, name, FILE_COMMON); if(h != INVALID_HANDLE) { do { FileReader reader(name, FILE_COMMON); reader.read(this); } while(FileFindNext(h, name)); FileFindClose(h); } return true; }
見つかった各ファイルは、処理のために新しいFileReaderクラスに渡されます。クラスは、読み取りモードでファイルを開く役割を果たします。また、すべての行を順番にロードし、すぐにフィードインターフェイスに渡します。
class Feed { public: virtual bool feed(const int dump) = 0; }; class FileReader { protected: int dump; public: FileReader(const string name, const int flags = 0) { dump = FileOpen(name, FILE_READ | FILE_CSV | FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_ANSI | flags, ','); } virtual bool isReady() const { return dump != INVALID_HANDLE; } virtual bool read(Feed &pass) { if(!isReady()) return false; while(!FileIsEnding(dump)) { if(!pass.feed(dump)) { return false; } } return true; } };
ご想像のとおり、これをFileReader内で渡したため、Feedインターフェイスを群に直接実装する必要があります。
class Swarm: public Feed { private: ... int _read; int _unique; int _restored; BinaryTree<ulong> merge; public: ... virtual bool feed(const int dump) override { const ulong value = (ulong)FileReadString(dump); _read++; if(!index.add(value)) _restored++; // just added into the tree else if(!merge.add(value)) _unique++; // was already indexed, hitting _unique in total return true; } ...
このメソッドは、_read、_unique、_restored各変数を使用して、(すべてのファイルから)読み取られた要素の総数、インデックスに追加された要素の数、および追加されていない(すでにインデックスにある)要素の数を計算します。グループは独立して動作するため、異なるグループのインデックスは重複する可能性があります。
これらの統計は、検索空間が完全に探索された瞬間、または完全に探索されそうな瞬間を判断する上で重要です。この場合、_uniqueの数は可能なパラメータの組み合わせの数に近づきます。
完了したパスの数が増えると、共有履歴からの一意の点がローカルインデックスにロードされます。「calculate」の次の実行後、インデックスは新しいチェックポイントを受け取り、保存されたファイルのサイズは絶えず大きくなります。徐々に、ファイル内の重複する要素が優勢になり始めます。これには追加のコストが必要になりますが、EAの取引活動の再計算よりも少なくなります。これにより、処理が最適化空間の完全な範囲に近づくにつれて、後続の各グループ(テスタータスク)の処理でPSOサイクルが加速されます。
粒子群最適化のクラス図
並列コンピューティングテスト
複数のスレッドでアルゴリズムのパフォーマンスをテストするために、古いスクリプトをPPSO.mq5 エキスパートアドバイザーに変換してみましょう。取引環境はまだ必要ないため、数学計算モードで実行されます。
テスト目的の関数セットは同じであり、それらを実装するクラスは実質的に変更されていません。入力変数で特定のテストが選択されます。
enum TEST { Sphere, Griewank, Rosenbrock }; sinput int Cycles = 100; sinput TEST TestCase = Sphere; sinput int SwarmSize = 0; input int GroupCount = 0;
ここでは、サイクル数、群れのサイズ、グループの数を指定することもできます。これらはすべて、ファンクターの実装、特にSwarmコンストラクタで使用されます。デフォルトのゼロ値は、タスクのディメンションに基づく自動選択を意味します。
class BaseFunctor: public Functor { protected: const int params; double max[], min[], steps[]; double optimum; double result[]; public: ... virtual void test() { Swarm swarm(SwarmSize, GroupCount, params, max, min, steps); optimum = swarm.optimize(this, Cycles); swarm.getSolution(result); } double getSolution(double &output[]) const { ArrayCopy(output, result); return optimum; } };
すべての計算はOnTesterハンドラから開始されます。GroupCountパラメータ(テスターの反復が編成される)は、異なるスレッドのインスタンスに異なる粒子が含まれるようにするためのランダマイザーとして使用されます。TestCaseパラメータに応じてテストファンクターが作成されます。次にfunctor.test()メソッドが呼び出されます。その後、functor.getSolution()を使用して結果を読み取り、フレームでターミナルに送信できます。
double OnTester() { MathSrand(GroupCount); // reproducible randomization BaseFunctor *functor = NULL; switch(TestCase) { case Sphere: functor = new PSOTests::Sphere(); break; case Griewank: functor = new PSOTests::Griewank(); break; case Rosenbrock: functor = new PSOTests::Rosenbrock(); break; } functor.test(); double output[]; double result = functor.getSolution(output); if(MQLInfoInteger(MQL_OPTIMIZATION)) { FrameAdd("PSO", 0xC0DE, result, output); } else { Print("Solution: ", result); for(int i = 0; i < ArraySize(output); i++) { Print(i, " ", output[i]); } } delete functor; return result; }
一連の関数OnTesterInit、OnTesterPass、OnTesterDeinitがターミナルで機能します。フレームを収集し、送信されたフレームから最適なソリューションを決定します。
int passcount = 0; double best = -DBL_MAX; double location[]; void OnTesterPass() { ulong pass; string name; long id; double r; double data[]; while(FrameNext(pass, name, id, r, data)) { // compare r with all other passes results if(r > best) { best = r; ArrayCopy(location, data); } Print(passcount, " ", id); const int n = ArraySize(data); ArrayResize(data, n + 1); data[n] = r; ArrayPrint(data, 12); passcount++; } } void OnTesterDeinit() { Print("Solution: ", best); ArrayPrint(location); }
パスカウンター、そのシーケンス番号(複雑なタスクで、データの違いにより1つのスレッドが別のスレッドを追い抜く場合)、目的関数の値、および対応するパラメータのデータがログに書き込まれます。最終決定はOnTesterDeinitで行われます。
テスターだけでなく、通常のチャートでも実行されるエキスパートアドバイザーを有効にしましょう。この場合、PSOアルゴリズムは通常のシングルスレッドモードで実行されます。
int OnInit() { if(!MQLInfoInteger(MQL_TESTER)) { EventSetTimer(1); } return INIT_SUCCEEDED; } void OnTimer() { EventKillTimer(); OnTester(); }
それがどのように機能するか見てみましょう。入力パラメータの次の値が使用されます。
- Cycles — 100;
- TestCase — Griewank;
- SwarmSize — 100;
- GroupCount — 10;
チャートでエキスパートアドバイザーを起動すると、次のログが書き込まれます。
Successive PSO of Griewank PSO[2] created: 100/10 PSO Processing... Cycle 0 done, skipped 0 of 100 / -1.000317162069485 Cycle 10 done, skipped 0 of 100 / -0.2784790501384311 Cycle 20 done, skipped 0 of 100 / -0.1879188508394087 Cycle 30 done, skipped 0 of 100 / -0.06938172138150922 Cycle 40 done, skipped 0 of 100 / -0.04958694402304631 Cycle 50 done, skipped 0 of 100 / -0.0045818974357138 Cycle 60 done, skipped 0 of 100 / -0.0045818974357138 Cycle 70 done, skipped 0 of 100 / -0.002161613760466419 Cycle 80 done, skipped 0 of 100 / -0.0008991629607246754 Cycle 90 done, skipped 0 of 100 / -1.620636881582982e-05 Cycle 99 done, skipped 0 of 100 / -1.342285474092986e-05 PSO Finished 9948 of 10000 planned calculations: true Solution: -1.342285474092986e-05 0 0.004966759354110293 1 0.002079707592422949
テストケースはすばやく(1〜2秒以内に)計算されるため、時間を測定する意味はありません。これは、実際の取引タスクのために後で追加されます。
次に、テスターでEAを選択し、「モデリング」リストで「数学計算」を設定し、GroupCountを除いて、EAに上記のパラメータを使用します。このパラメータは最適化に使用されます。したがって、初期値と最終値を設定します。たとえば、0と3を手順1で設定して、4つのグループ(コアの数に等しい)を生成します。すべてのグループのサイズは100(SwarmSize、群全体)になります。プロセッサコアの数が十分であれば(すべてのグループがエージェントで並行して動作する場合)、パフォーマンスへの影響はないはずですが、最適化空間の追加チェックを通じてソリューションの精度が向上します。次のログを受け取ることができます。
Parallel PSO of Griewank -12.550070232909 -0.002332638407 -0.039510275469 -3.139749741924 4.438437934965 -0.007396077598 3.139620588383 4.438298282495 -0.007396126543 0.000374731767 -0.000072178955 -0.000000071551 Solution: -7.1550806279852e-08 (after 4 passes) 0.00037 -0.00007
これで、PSOアルゴリズムの並列変更が最適化モードのテスターで利用できるようになったことを確認しました。しかし、これまでのところ、それは数学計算を使用したテストにすぎません。次に、PSOを適応させて、取引環境でエキスパートアドバイザーを最適化します。
エキスパートアドバイザーの仮想化と最適化(MetaTrader5のMQL4API)
PSOエンジンを使用してエキスパートアドバイザーを最適化するには、一連の入力パラメータに基づいて履歴の取引をシミュレートし、統計を計算できるファンクターを実装する必要があります。
これにより、標準のオプティマイザに加えてまたはその代わりに独自のオプティマイザを作成するときに多くのアプリケーション開発者が直面するジレンマが発生します。まず第一に、相場、口座の状態、取引のアーカイブなど、取引環境をどのように提供するかということです。数学計算モードを使用する場合は、何らかの方法で準備してから、必要なデータをエキスパートアドバイザー(エージェント)に渡す必要があります。これには、多くの取引機能を「透過的に」エミュレートするAPI中間層の開発が必要です。これにより、エキスパートアドバイザーが通常のオンラインモードと同様に機能できるようになります。
これを回避するために、完全にMQLで作成され、標準の履歴データ構造、特にティックとバーを利用する既存の仮想取引ソリューションを使用することにしました。これは、fxsaberによるVirtualライブラリです。これにより、オンライン(チャートでの定期的な自己最適化など)とテスターの両方で、利用可能な履歴に関するエキスパートアドバイザーの仮想パスを計算できます。後者の場合、通常のティックモード(「すべてのティック」、「実際のティックに基づくすべてのティック」)、または「OHLC on M1」を使用して、システムをすばやく、しかしより大まかに見積もることができます( 毎分4ティック)。
Virtual.mqhヘッダファイル(必要な依存関係とともにダウンロードされます)をEAコードにインクルードした後、次の行を使用して仮想テストを簡単に整理できます。
MqlTick _ticks[]; // global array ... // copy/collect ticks[] VIRTUAL::Tester(_ticks, OnTick /*, TesterStatistics(STAT_INITIAL_DEPOSIT)*/ ); Print(VIRTUAL::ToString(INT_MAX)); // output virtual trades in the log const double result = TesterStatistics(STAT_PROFIT); // get required performance meter VIRTUAL::ResetTickets(); // optional VIRTUAL::Delete(); // optional
すべての操作は、static VIRTUAL::Testerメソッドによって実行されます。このメソッドに、希望する履歴期間と詳細のティックの事前入力された配列、OnTick関数へのポインタ(オンライン取引から仮想への切り替えのロジックが含まれている場合は、標準ハンドラを使用できます)、初期預金(オプションで、指定されていない場合は現在の口座残金が使用されます)のデータを渡す必要があります。上記のフラグメントがOnTesterハンドラに配置されている場合(そこに配置されます)、テスターの当初預金を渡すことができます。仮想取引の結果を確認するには、おなじみのTesterStatistics関数を呼び出します。この関数は、ライブラリに接続した後、他の多くのMQL API関数と同様に実際に「重複」していることがわかります(必要に応じてソースコードを確認してください)。この「重複」は、取引が実際に実行される元のカーネル関数への呼び出しを委任するのに十分スマートです。TesterStatisticsのすべての標準指標が仮想取引中にライブラリで計算されるわけではありません。
ライブラリはMetaTrader4取引APIに基づいています。つまり、MQL5で記述されていても、コードで「古い」関数を使用するエキスパートアドバイザーにのみ適しており、同じ作者による別の有名なライブラリ(MT4Orders)のおかげで、MetaTrader5環境で実行できます。
テストは、修正したExprBot.mq5 EAを使用して実行されます。これは、数式の計算(第2部)の記事で最初に紹介されました。EAはMT4Ordersを使用して実装されます。ExprBotPSO.mq5という名前の新しいバージョンが添付ファイルにあります。
エキスパートアドバイザーは、パーサーエンジンを使用し、式に基づいて取引シグナルを計算します。これの利点については後で説明します。取引ストラテジーは同じで、指定された拡散閾値を考慮した、2つの移動平均の交点です。EA設定とシグナルの式は次のとおりです。
input string SignalBuy = "EMA_OPEN_{Fast}(0)/EMA_OPEN_{Slow}(0) > 1 + Threshold"; input string SignalSell = "EMA_OPEN_{Fast}(0)/EMA_OPEN_{Slow}(0) < 1 - Threshold"; input string Variables = "Threshold=0.001"; input int Fast = 10; input int Slow = 21;
入力変数が式にどのように代入されるか、および組み込みのEMA関数が対応するインディケーターとどのように統合されるかについて質問がある場合は、前述の記事を読むことをお勧めします。新しいロボットは同じ原理を使用しますが、少し改善されます。
パーサーエンジンがバージョンv1.1に更新され、含まれています。古いバージョンは動作しません。
後で説明するシグナルの入力パラメータに加えて、EAには仮想テストとPSOアルゴリズムを管理するためのパラメータがあります。
- VirtualTester - 仮想テストや最適化を可能にするフラグ。デフォルトはfalseで、通常の操作を意味します。
- Estimator — 最適化を実行するための変数。デフォルトはSTAT_PROFITです。
- InternalOptimization — 仮想モードでの最適化を有効にするフラグ。デフォルトはfalseで、シングルパス仮想取引を意味します。Trueは、PSOメソッドによる内部最適化を開始します。
- PSO_Enable — PSOを有効/無効にします。
- PSO_Cycles — 各パスのPSO再計算サイクルの数。値が大きいほど、PSO検索の品質は向上しますが、フィードバック(ロギング)なしでシングルパスの実行時間が長くなります。
- PSO_SwarmSize — 群のサイズ。デフォルトでは0で、パラメータの数に基づく自動の経験的選択を意味します。
- PSO_GroupCount — グループの数。これは、複数のパスを編成するための増分パラメータです。0からコア/エージェントの数までの値から始めて、その後増加します。
- PSO_RandomSeed — ランダマイザー。グループ番号は各グループで追加されるため、すべての初期化が異なります。
VirtualTesterモードでは、EAはOnTickのティックを配列に収集します。次に、OnTesterで、仮想ライブラリはこの配列を使用して取引し、仮想操作でコードを実行できるようにする特別な設定フラグを使用して同じOnTickハンドラを呼び出します。
したがって、PSO_SwarmSize粒子サイズの群の再計算のPSO_Cyclesのサイクルは、PSO_GroupCount値で増分されるごとに実行されます。最適化空間でPSO_GroupCount * PSO_Cycles * PSO_SwarmSize = N点をテストすることになります。各点は、取引システムの仮想実行です。
最良の結果を得るには、試行錯誤を使用して適切なPSOパラメータを見つけます。コンポーネントの数は、N個のテストに対して変更できます。同じ点にヒットする可能性があるため、テストの最終的な数はN未満になります(点は群の二分木に格納されます)。
エージェントは、次のタスクが送信されたときにのみデータを交換します。並行して実行されるタスクは、まだ互いの結果を確認しておらず、ある程度の確率でいくつかの同一の座標を計算することもできます。
もちろん、ExprBotPSOエキスパートアドバイザーには、前の例で検討したものと一般的に類似したファンクタークラスが含まれています。これらには、群インスタンスを作成してその中で最適化を実行し、結果をメンバー変数(optimum、result[])に保存する「test」メソッドが含まれます。
class BaseFunctor: public Functor { ... public: virtual bool test(void) { Swarm swarm(PSO_SwarmSize, PSO_GroupCount, params, max, min, steps); if(MQLInfoInteger(MQL_OPTIMIZATION)) { if(!swarm.restoreIndex()) return false; } optimum = swarm.optimize(this, PSO_Cycles); swarm.getSolution(result); if(MQLInfoInteger(MQL_OPTIMIZATION)) { swarm.exportIndex(PSO_GroupCount); } return true; } };
前のセクションで説明したrestoreIndexメソッドとexportIndexメソッドの使用法を確認するのはこれが初めてです。エキスパートアドバイザーの最適化タスクは通常多くの計算を必要とするため(パラメータとグループ、各グループは1つのテスターパスです)、エージェントは情報を交換する必要があります。
仮想EAテストは、宣言された順序に従って「calculate」メソッドで実行されます。新しいSettingクラスが最適化空間の初期化で使用されます。
class WorkerFunctor: public BaseFunctor { string names[]; public: WorkerFunctor(const Settings &s): BaseFunctor(s.size()) { s.getNames(names); for(int i = 0; i < params; i++) { max[i] = s.get<double>(i, SET_COLUMN_STOP); min[i] = s.get<double>(i, SET_COLUMN_START); steps[i] = s.get<double>(i, SET_COLUMN_STEP); } } virtual double calculate(const double &vec[]) { VIRTUAL::Tester(_ticks, OnTick, TesterStatistics(STAT_INITIAL_DEPOSIT)); VIRTUAL::ResetTickets(); const double r = TesterStatistics(Estimator); VIRTUAL::Delete(); return r; } };
重要なのは、最適化を開始するために、ユーザは通常の方法でEAの入力パラメータを構成するということです。ただし、群アルゴリズムは、タスクの並列化(グループ番号の増分による)にのみテスターを使用します。したがって、EAは最適化パラメータの設定を読み取り、各エージェントに送信される補助ファイルに保存し、テスターでこれらの設定をリセットし、グループ番号で最適化を割り当てる必要があります。Settingsクラスは、補助ファイルからパラメータを読み取ります。このファイルは「EA_name.mq5.csv」で、ディレクティブを使用して接続する必要があります。
#define PPSO_SHARED_SETTINGS __FILE__ + ".csv" #property tester_file PPSO_SHARED_SETTINGS
Settingsクラスは添付ファイルで参照できます。このクラスは、CSVファイルを1行ずつ読み取ります。ファイルには次の列が必要です。
#define MAX_COLUMN 4 #define SET_COLUMN_NAME 0 #define SET_COLUMN_START 1 #define SET_COLUMN_STEP 2 #define SET_COLUMN_STOP 3
それらはすべて内部配列に記憶されており、名前またはインデックスで「get」メソッドを介して利用できます。isVoid()メソッドは、設定がないことを示します(ファイルを読み取ることができなかった、ファイルが空である、または絞り込み形式である)。
設定は、OnTesterInitハンドラのファイルに書き込まれます(以下を参照)。
事前にMQL5/Filesフォルダに空の「EA_name.mq5.csv」ファイルを手動で作成することをお勧めします。そうしないと、最初の最適化調整で問題が発生する可能性があります。
残念ながら、最初の起動時にこのファイルを自動的に作成しますが、ファイルをエージェントに送信しません。そのため、EA初期化はINIT_PARAMETERS_INCORRECTエラーで終了します。テスターは接続されたリソースに関する情報をキャッシュし、ユーザがテスター設定のドロップダウンリストでEAを再選択するまで新しく追加されたファイルを考慮しないため、最適化を繰り返し起動しても送信されません。その後初めて、ファイルを更新してエージェントに送信できます。したがって、事前にファイルを作成する方が簡単です。
string header[]; void OnTesterInit() { int h = FileOpen(PPSO_SHARED_SETTINGS, FILE_ANSI|FILE_WRITE|FILE_CSV, ','); if(h == INVALID_HANDLE) { Print("FileSave error: ", GetLastError()); } MqlParam parameters[]; string names[]; EXPERT::Parameters(0, parameters, names); for(int i = 0; i < ArraySize(names); i++) { if(ResetOptimizableParam<double>(names[i], h)) { const int n = ArraySize(header); ArrayResize(header, n + 1); header[n] = names[i]; } } FileClose(h); // 5008 bool enabled; long value, start, step, stop; if(ParameterGetRange("PSO_GroupCount", enabled, value, start, step, stop)) { if(!enabled) { const int cores = TerminalInfoInteger(TERMINAL_CPU_CORES); Print("PSO_GroupCount is set to default (number of cores): ", cores); ParameterSetRange("PSO_GroupCount", true, 0, 1, 1, cores); } } // remove CRC indices from previous optimization runs Swarm::removeIndex(); }
追加のResetOptimizableParam関数は、最適化フラグが有効になっているパラメータを検索し、そのようなフラグをリセットするために使用されます。また、OnTesterInitではfxsaberによるExpertライブラリを使用してこれらのパラメータの名前を記憶しているため、結果をより視覚的に明確に表示できます。ただし、主にライブラリが必要だったのは、ParameterGetRange/ParameterSetRange標準関数を呼び出すために名前を事前に知っておく必要があるためですが、MQL APIではパラメータのリストを取得できません。これにより、コードがより普遍的になるので、特別な変更を加えずに任意のEAに含めることができます。
template<typename T> bool ResetOptimizableParam(const string name, const int h) { bool enabled; T value, start, step, stop; if(ParameterGetRange(name, enabled, value, start, step, stop)) { // disable all native optimization except for PSO-related params // preserve original settings in the file h if((StringFind(name, "PSO_") != 0) && enabled) { ParameterSetRange(name, false, value, start, step, stop); FileWrite(h, name, start, step, stop); // 5007 return true; } } return false; }
エージェントで実行されるOnInitハンドラでは、設定は次のようにSettingsグローバルオブジェクトに読み込まれます。
Settings settings; int OnInit() { ... FileReader f(PPSO_SHARED_SETTINGS); if(f.isReady() && f.read(settings)) { const int n = settings.size(); Print("Got settings: ", n); } else { if(MQLInfoInteger(MQL_OPTIMIZATION)) { Print("FileLoad error: ", GetLastError()); return INIT_PARAMETERS_INCORRECT; } else { Print("WARNING!Virtual optimization inside single pass - slowest mode, debugging only"); } } ... }
後で説明するように、このオブジェクトはOnTesterハンドラで作成されたWorkerFunctorオブジェクトに渡され、そこですべての計算と最適化が実行されます。計算を開始する前に、ティックを収集する必要があります。これは、OnTickハンドラで実行されます。
bool OnTesterCalled = false; void OnTick() { if(VirtualTester && !OnTesterCalled) { MqlTick _tick; SymbolInfoTick(_Symbol, _tick); const int n = ArraySize(_ticks); ArrayResize(_ticks, n + 1, n / 2); _ticks[n] = _tick; return; // skip all time scope and collect ticks } ... // trading goes on here }
OnTesterで直接CopyTicksRange関数を呼び出す代わりに上記のメソッドを使用するのはなぜでしょうか。分節135968¶>まず、この関数はティックごとのモードでのみ機能しますが、高速OHLC M1モード(毎分4ティック)のサポートを提供する必要があります。次に、ティック生成モードで返される配列のサイズは、何らかの理由で131072に制限されます(実際のティックを操作する場合、そのような制限はありません)。
OnTesterCalled変数は最初はfalseに等しいため、ティック履歴が収集されます。OnTesterCalledは、PSOを開始する前に、後でOnTesterでtrueに設定されます。次に、Swarmオブジェクトはファンクターの計算の反復を開始します。ここでは、同じOnTickを参照するVIRTUAL::Testerが呼び出されます。OnTesterCalledはtrueに等しくなり、制御はティック収集モードではなく取引ロジックモードに移されます。これは少し後で検討されます。将来的に、PSOライブラリがさらに発展するにつれて、ライブラリヘッダファイルのOnTickハンドラを置き換えることにより、既存のエキスパートアドバイザーへの統合を簡素化するメカニズムが登場する可能性があります。
それまでは、OnTester(簡略化された形式)が使用されます。
double OnTester() { if(VirtualTester) { OnTesterCalled = true; // MQL API implies some limitations for CopyTicksRange function, so ticks are collected in OnTick const int size = ArraySize(_ticks); PrintFormat("Ticks size=%d error=%d", size, GetLastError()); if(size <= 0) return 0; if(settings.isVoid() || !InternalOptimization) // fallback to a single virtual test without PSO { VIRTUAL::Tester(_ticks, OnTick, TesterStatistics(STAT_INITIAL_DEPOSIT)); Print(VIRTUAL::ToString(INT_MAX)); Print("Trades: ", VIRTUAL::VirtualOrdersHistoryTotal()); return TesterStatistics(Estimator); } settings.print(); const int n = settings.size(); if(PSO_Enable) { MathSrand(PSO_GroupCount + PSO_RandomSeed); // reproducable randomization WorkerFunctor worker(settings); Swarm::Stats stats; if(worker.test(&stats)) { double output[]; double result = worker.getSolution(output); if(MQLInfoInteger(MQL_OPTIMIZATION)) { FrameAdd(StringFormat("PSO%d/%d", stats.done, stats.planned), PSO_GroupCount, result, output); } ArrayResize(output, n + 1); output[n] = result; ArrayPrint(output); return result; } } ... return 0; } return TesterStatistics(Estimator); }
上記のコードは、「settings」オブジェクトからの一連のパラメータによるWorkerFunctorの作成と、「test」メソッドを使用した群の起動を示しています。得られた結果はフレームで端末に送信され、そこでOnTesterPassで受信されます。
OnTesterPassハンドラはPPSOテストEAのハンドラと似ていますが、フレームで受信したデータがログではなく、PPSO-EA-name-date_timeというタイトルのCSVファイルに出力される点が異なります。
粒子群最適化のシーケンス図
最後に、取引ストラテジーに戻りましょう。これは、数式の計算(第2部稿で使用されているものとほぼ同じです。ただし、仮想取引を有効にするには、いくつかの調整が必要です。以前のシグナル式は、ゼロバーの始値に基づいてEMA指標を計算しています。
input string SignalBuy = "EMA_OPEN_{Fast}(0)/EMA_OPEN_{Slow}(0) > 1 + Threshold"; input string SignalSell = "EMA_OPEN_{Fast}(0)/EMA_OPEN_{Slow}(0) < 1 - Threshold";
ここでは、履歴バーから読み取る必要があります(計算はパスの最後にOnTesterから実行されるため)。過去の「現在の」バーの数は簡単に判別できます。仮想ライブラリはTimeCurrentシステム関数をオーバーライドするため、OnTickで次のように記述できます。
const int bar = iBarShift(_Symbol, PERIOD_CURRENT, TimeCurrent());
現在のバー番号を、「Bar」などの適切な名前で式の変数テーブルに追加する必要があります。そうすると、シグナル式を次のように書き直すことができます。
input string SignalBuy = "EMA_OPEN_{Fast}(Bar)/EMA_OPEN_{Slow}(Bar) > 1 + Threshold"; input string SignalSell = "EMA_OPEN_{Fast}(Bar)/EMA_OPEN_{Slow}(Bar) < 1 - Threshold";
更新されたバージョンのパーサーでは、変数(バー番号)を変更し、次の値を使用して数式を計算するときに、新しい 「with」メソッド(これもOnTickにあります)が中間的に呼び出されます。
const int bar = iBarShift(_Symbol, PERIOD_CURRENT, TimeCurrent()); // NEW bool buy = p1.with("Bar", bar).resolve(); // WAS: bool buy = p1.resolve(); bool sell = p2.with("Bar", bar).resolve(); // WAS: bool sell = p2.resolve();
さらに、OnTick取引コードに変更はありません。
ただし、さらに変更が必要です。
現在の数式は、設定で指定され、式内の変数に変換された固定EMA期間を使用します。ただし、期間は最適化プロセス中に変更する必要があります。つまり、インディケーターのさまざまなインスタンスを使用する必要があります。問題は、群によるパラメータ調整を使用した仮想最適化が、OnTester関数の最後のテスターパス内で実行されることです。ここでインディケーターハンドルを作成するには遅すぎます。
これは、仮想最適化のグローバルな問題で、3つの明白な解決策があります。
- インディケーターはまったく使用しない - 指標を利用しない取引システムにはここで利点があります。
- EA内で独立して、特別な方法で指標を計算する - 標準指標と比較しても、これが最速の方法であるため、多くの人がこの方法を使用しますが、非常に手間がかかります。
- 設定からパラメータのすべての組み合わせのインディケーターのセットを事前に作成する - リソースを大量に消費し、パラメータの範囲を制限する必要がある場合があります。
最後の方法は、ティックごとにシグナルを計算するシステムでは疑問があります。実際、仮想履歴のすべてのバーはすでに閉じられており、インディケーターはすでに計算されています。つまり、バーシグナルのみが使用可能です。そのような履歴でバーのオープンを制御せずにシステムを実行すると、非仮想ティックと比較した場合、低品質ではるかに少ない取引が生成されます。
ここでのエキスパートアドバイザーはバーで取引しているので、これは問題ではありません。この状況は、MetaTrader5の一部の標準エキスパートアドバイザーに典型的なものです。新しいバーオープンイベントがどのように検出されるかを理解する必要があります。単一のティックボリュームコントロールを使用する方法は、すべてのバーがすでにティックで埋められているため、仮想履歴には適していません。したがって、その時間を前のバーと比較して、新しいバーを定義することをお勧めします。
式エンジンは、3番目のオプションを使用して説明されている問題を解決するように拡張されています。単一のMAインディケーター関数(MAIndicatorFunc)に加えて、MAファン関数(MultiMAIndicatorFunc、Indicators.mqhを参照)を作成しました。名前は「M_」プレフィックスで始まり、最小期間、期間ステップ、最大期間が含まれている必要があります。次に例を示します。
input string SignalBuy = "M_EMA_OPEN_9_6_27(Fast,Bar)/M_EMA_OPEN_9_6_27(Slow,Bar) > 1 + T"; input string SignalSell = "M_EMA_OPEN_9_6_27(Fast,Bar)/M_EMA_OPEN_9_6_27(Slow,Bar) < 1 - T";
計算方法と価格タイプは、以前と同じように名前に示されています。ここで、EMAファンは、9から27(両端を含む)の期間の始値に基づいて、手順6で作成されます。
式ライブラリのもう1つの革新は、TesterStatisticsからの取引統計へのアクセスを提供する変数のセットです(TesterStats.mqhを参照)。このセットに基づいて、式入力をEAに追加することができます。これにより、ターゲット値を任意の式として設定できます。この変数が入力されると、Estimatorは無視されます。特に、STAT_PROFIT_FACTOR(ゼロ損失に対しては未定義)の代わりに、同様の式のより「スムーズ」なインディケーターを「Estimator」に設定できます((GROSSPROFIT-(1/(TRADES+1)))/-(GROSSLOSS-1/(TRADES +1)))。
これで、PSOメソッドを使用して仮想取引の最適化を実行する準備が整いました。
実用的なテスト
テスターを用意しましょう。遅い最適化、つまりすべてのパラメータの完全な反復を使用します。EAパラメータの選択的反復はそのサイクル内の群れによって実行されますが、実行ごとにグループ番号のみが変更されるため、この場合は遅くなりません。遺伝的アルゴリズムは3つの理由で使用できません。まず、パラメータのすべての組み合わせ(この場合は特定のグループ数)が計算されることが保証されません。第2に、その特定の性質により、グループ番号は単にPSOデータ構造体のランダマイザーであるため、グループ番号とその成功の間に依存関係がないという事実を考慮せずに、より魅力的な結果を生成するパラメータに向かって徐々に「シフト」します。 第3に、グループの数は通常、遺伝的アプローチを使用するのに十分な数ではありません。
最適化は、最大のカスタム基準によって実行されます。
まず、仮想ライブラリを無効にして、エキスパートアドバイザーを通常の方法で最適化します(ExprBotPSO-standard-optimization.setファイル)。最適化のためのパラメータの組み合わせの数はデモ用に少なくなっています。FastおよびSlowパラメータは9から45まで6ごとに変化し、Tパラメータは0.0025ごとに0から0.01まで変化します。
EURUSD、H1は、実際のティックを使用して、2020年の初めからの範囲です。次の結果が得られます。
標準最適化結果表
ログによると、2つのエージェントのツールの最適化には約21分かかります。
Experts optimization frame expert ExprBotPSO (EURUSD,H1) processing started Tester Experts\ExprBotPSO.ex5 on EURUSD,H1 from 2020.01.01 00:00 to 2020.08.01 00:00 Tester complete optimization started ... Core 2 connected Core 1 connected Core 2 authorized (agent build 2572) Core 1 authorized (agent build 2572) ... Tester optimization finished, total passes 245 Statistics optimization done in 20 minutes 55 seconds Statistics shortest pass 0:00:05.691, longest pass 0:00:23.114, average pass 0:00:10.206
次に、仮想取引とPSOを有効にしてエキスパートアドバイザーを最適化します(ExprBotPSO-virtual-pso-optimization.set)。 4に等しいグループの数は、PSO_GroupCountパラメータを0から3まで繰り返すことによって決定されます。最適化が有効になっている他の操作パラメータは、標準の最適化では強制的に無効になりますが、 PSOアルゴリズムを使用する内部仮想最適化のためにCSVファイルでエージェントに転送されます。
繰り返しますが、生成されたティックまたはOHLCM1を使用してすばやく計算することもできますが、実際のティックによるシミュレーションを使用します。ティックは仮想取引のためにテスターで収集されるため、ここでは数学的計算を使用できません。
テスターログでは、次の情報を取得できます。
Tester input parameter 'Fast' set to: enable=false, value=9, start=9, step=6, stop=45 Tester input parameter 'Slow' set to: enable=false, value=21, start=9, step=6, stop=45 Tester input parameter 'T' set to: enable=false, value=0, start=0, step=0.0025, stop=0.01 Experts optimization frame expert ExprBotPSO (EURUSD,H1) processing started Tester Experts\ExprBotPSO.ex5 on EURUSD,H1 from 2020.01.01 00:00 to 2020.08.01 00:00 Tester complete optimization started ... Core 1 connected Core 2 connected Core 2 authorized (agent build 2572) Core 1 authorized (agent build 2572) ... Tester optimization finished, total passes 4 Statistics optimization done in 4 minutes 00 seconds Statistics shortest pass 0:01:27.723, longest pass 0:02:24.841, average pass 0:01:56.597 Statistics 4 frames (784 bytes total, 196 bytes per frame) received
各「パス」は仮想最適化のパッケージになっているため、長くなっています。しかし、それらの総数は少なく、合計時間は大幅に短縮されます(わずか4分)。
フレームからのメッセージはログで受信されます(各グループの最良の読み取り値が表示されます)。ただし、実際の取引結果と仮想取引の結果はわずかに異なります。
22:22:52.261 ExprBotPSO (EURUSD,H1) 2 tmp-files deleted 22:25:07.981 ExprBotPSO (EURUSD,H1) 0 PSO75/1500 0 1974.400000000025 22:25:23.348 ExprBotPSO (EURUSD,H1) 2 PSO84/1500 2 402.6000000000062 22:26:51.165 ExprBotPSO (EURUSD,H1) 3 PSO70/1500 3 455.000000000003 22:26:52.451 ExprBotPSO (EURUSD,H1) 1 PSO79/1500 1 458.3000000000047 22:26:52.466 ExprBotPSO (EURUSD,H1) Solution: 1974.400000000025 22:26:52.466 ExprBotPSO (EURUSD,H1) 39.00000 15.00000 0.00500
テスターにはMQLライブラリで繰り返すことができない特定の操作機能があるため、結果は完全には一致しません(ティックごとのインディケーターフリー戦略があったとしても)。 それらのほんの一部を次に示します。
- 市場価格に近い指値注文は、さまざまな方法でトリガーされる
- 証拠金が計算されていないまたは正確に計算されていない
- 手数料が自動的には計算されていない(MQL APIの制限)が、追加の入力パラメータを使用してプログラムできる
- ネッティングモードでの注文と取引の会計処理は異なる場合がある
- 現在の銘柄のみがサポートされる
仮想ライブラリの詳細については、関連するドキュメントとディスカッションを参照してください。
デバッグ目的また群操作を理解する目的のために、テストEAは、通常のテスター実行内では1つのコアで仮想最適化モードをサポートします。設定の例は、以下に添付されているExprBotPSO-virtual-internal-optimization-single-pass.setファイルにあります。テスターで最適化を無効にすることを忘れないでください。
中間結果は、テスターログに詳細に書き込まれます。各サイクルで、各粒子の目的関数の位置と値が、指定されたPSO_Cyclesから出力されます。粒子がすでにチェックされた座標にヒットした場合、計算はスキップされます。
Ticks size=15060113 error=0 [,0] [,1] [,2] [,3] [0,] "Fast" "9" "6" "45" [1,] "Slow" "9" "6" "45" [2,] "T" "0" "0.0025" "0.01" PSO[3] created: 15/3 PSO Processing... Fast:9.0, Slow:33.0, T:0.0025, 1.31285 Fast:21.0, Slow:21.0, T:0.0025, -1.0 Fast:15.0, Slow:33.0, T:0.0075, -1.0 Fast:27.0, Slow:39.0, T:0.0025, 0.07673 Fast:9.0, Slow:9.0, T:0.005, -1.0 Fast:33.0, Slow:21.0, T:0.01, -1.0 Fast:39.0, Slow:45.0, T:0.0025, -1.0 Fast:15.0, Slow:15.0, T:0.0025, -1.0 Fast:33.0, Slow:21.0, T:0.0, 0.32895 Fast:33.0, Slow:39.0, T:0.0075, -1.0 Fast:33.0, Slow:15.0, T:0.005, 384.5 Fast:15.0, Slow:27.0, T:0.0, 2.44486 Fast:39.0, Slow:27.0, T:0.0025, 11.41199 Fast:9.0, Slow:15.0, T:0.0, 1.08838 Fast:33.0, Slow:27.0, T:0.0075, -1.0 Cycle 0 done, skipped 0 of 15 / 384.5000000000009 ... Fast:45.0, Slow:9.0, T:0.0025, 0.86209 Fast:21.0, Slow:15.0, T:0.005, -1.0 Cycle 15 done, skipped 13 of 15 / 402.6000000000062 Fast:21.0, Slow:15.0, T:0.0025, 101.4 Cycle 16 done, skipped 14 of 15 / 402.6000000000062 Fast:27.0, Slow:15.0, T:0.0025, 8.18754 Fast:39.0, Slow:15.0, T:0.005, 1974.40002 Cycle 17 done, skipped 13 of 15 / 1974.400000000025 Fast:45.0, Slow:9.0, T:0.005, 1.00344 Cycle 18 done, skipped 14 of 15 / 1974.400000000025 Cycle 19 done, skipped 15 of 15 / 1974.400000000025 PSO Finished 89 of 1500 planned calculations: true 39.00000 15.00000 0.00500 1974.40000 final balance 10000.00 USD OnTester result 1974.400000000025
最適化空間が小さいため、19サイクルで完全にカバーされました。もちろん、何百万もの組み合わせがある実際の問題では、状況は異なります。このような問題では、PSO_Cycles、PSO_SwarmSize、PSO_GroupCountの適切な組み合わせを見つけることが非常に重要です。
PSOでは、PSO_GroupCountごとに1つのテスターパスがPSO_Cycles*PSO_SwarmSize仮想シングルパスまで内部で実行されることを忘れないでください。そのため、進行状況の表示は通常よりも大幅に遅くなります。
多くのトレーダーは、組み込みの遺伝的最適化を連続して何度も実行することにより、最良の結果を得ようとします。これは無作為な初期化によってさまざまなテストを収集し、数回の実行後に進歩できます。PSOの場合、PSO_GroupCountは複数の遺伝的アルゴリズムの立ち上げの類似物として機能します。遺伝的アルゴリズムで10000に達する可能性のある単一実行の数は、PSOではPSO_Cycles*PSO_SwarmSizeの積(例: 100*100)の2つのコンポーネント間で分散する必要があります。PSO_Cyclesは遺伝的アルゴリズムの世代に類似しており、PSO_SwarmSizeは母集団のサイズです。
MQL5 APIエキスパートアドバイザーの仮想化
これまで、MQL4取引APIを使用して作成されたエキスパートアドバイザーの例を検討してきました。これは、仮想ライブラリの実装の詳細に関連していました。ただし、実装したかったのは「新しい」MQL5 API関数を使用してEAにPSOを使用する可能性です。この目的のために、MQL5 API呼び出しをMQL4 APIにリダイレクトするための実験的な中間層を開発しました。これはMT5Bridge.mqhファイルとして利用でき、操作には仮想ライブラリやMT4Ordersが必要です。
#include <fxsaber/Virtual/Virtual.mqh> #include <MT5Bridge.mqh> #include <Expert\Expert.mqh> #include <Expert\Signal\SignalMA.mqh> #include <Expert\Trailing\TrailingParabolicSAR.mqh> #include <Expert\Money\MoneySizeOptimized.mqh> ...
コードの先頭にVirtualとMT5Bridgeを追加した後で、他の#includeの前に、再定義された「ブリッジ」関数を介してMQL5 API関数が呼び出され、そこから「仮想」MQL4 API関数が呼び出されます。その結果、エキスパートアドバイザーを仮想的にテストおよび最適化することが可能です。特に、上記のExprBotPSOの例と同様にPSO最適化を実行できるようになりました。これには、テスターのファンクターとハンドラを作成(部分的にコピー)する必要があります。しかし、最もリソースと時間のかかるプロセスは、可変パラメータのインディケーターシグナルの適応に関係しています。
MT5Bridge.mqhは、その機能が広範囲にテストされていないため、実験的なステータスになっています。これは概念実証の研究です。デバッグとバグ修正にソースコードを使用できます。
終わりに
粒子群最適化アルゴリズムを検討し、テスターエージェントを使用したマルチスレッドをサポートするMQLに実装しました。オープンなPSO設定が利用できるため、組み込みの遺伝的最適化を使用する場合と比較して、プロセスをより柔軟に調整できます。入力パラメータで提供される設定に加えて、「optimize」メソッドの引数として使用した他の適応可能な係数をデフォルト値で試すことは理にかなっています(inertia(0.8)、selfBoost(0.4)、groupBoost(0.4))。これにより、アルゴリズムはより柔軟になりますが、特定のタスクの設定の選択がより困難になります。以下に添付されているPSOライブラリは、数学計算モード(仮想相場、インディケーター、取引に独自のメカニズムがある場合)、およびティックバーモード(Virtualのようなサードパーティの既製の取引エミュレーションクラスを使用する場合)で使用できます。
MetaQuotes Ltdによってロシア語から翻訳されました。
元の記事: https://www.mql5.com/ru/articles/8321
![DoEasyライブラリの時系列(第52部): 複数銘柄・複数期間の単一バッファ標準指標のクロスプラットフォーム化](https://c.mql5.com/2/40/MQL5-avatar-doeasy-library__4.png)
![トレーディングアルゴリズム開発への科学的アプローチ](https://c.mql5.com/2/40/algorithm_2.png)
![DoEasyライブラリの時系列(第51部): 複数銘柄・複数期間の複合標準指標](https://c.mql5.com/2/40/MQL5-avatar-doeasy-library__3.png)
![MQL5 - MetaTrader 5クライアントターミナルにビルトインされたトレードストラテジーの言語](https://c.mql5.com/i/registerlandings/logo-2.png)
- 無料取引アプリ
- 8千を超えるシグナルをコピー
- 金融ニュースで金融マーケットを探索