取引におけるニューラルネットワーク:周波数領域での異常検知(最終回)
はじめに
前回の記事では、多変量時系列における異常検出のための革新的なCATCHフレームワークを紹介しました。このフレームワークの著者らが提案した周波数領域解析手法は、外れ値や急激な変化といった点異常だけでなく、従来の手法では見逃されがちな、より複雑で潜在的なパターンも検出できます。CATCHフレームワークでは、フーリエ変換を用いてデータを時間領域から周波数領域表現へ変換することで、解析対象となる系列の特性をより詳細に分析できるようになります。
CATCHの大きな特徴の一つが、周波数パッチングの採用です。モデルはスペクトル全体を一括して解析するのではなく、特定の周波数帯域に対応する複数の断片(パッチ)へ分割して処理します。このアプローチにより、長期的なトレンドのような大域的特徴と、高周波成分に含まれる局所的な特徴の両方を詳細に解析できます。その結果、短時間のスパイクから部分系列レベルの複雑な異常まで、多様な種類の異常を高い精度で検出・分類することが可能になります。このようなスペクトルの分割処理により、時系列構造をよりきめ細かく分析できます。
フレームワークの重要な構成要素として、異なるデータチャネル間の関係を解析する適応モジュールがあります。このモジュールはマスク付きアテンション機構を実装しており、最も重要な相関関係に注目しながら、ノイズや不要な情報を効果的に除去します。この仕組みによって、正常な挙動の再構成精度が向上し、市場環境が変化する状況でもモデルのロバスト性が大幅に高まります。各チャネルを独立して処理する従来の手法とは異なり、CATCHはチャネル間の複雑な相互依存関係を考慮します。これは、ある市場の動きが他の市場セグメントへ影響を及ぼす金融データを扱う際に、特に重要な特徴です。
CATCHフレームワークの最後の段階では、元の時系列を再構成します。周波数領域で詳細な解析をおこない、潜在的な異常を特定した後、システムは逆変換を実行し、データを通常の時間領域表現へ戻します。元の値と再構成後の時系列との差分は、異常を示す信頼性の高い指標となり、市場動向の変化に迅速に対応することが可能になります。
CATCHフレームワークの著者による概念図を以下に示します。

前回の記事の実践セクションでは、提案されたアプローチについて、MQL5を用いた独自の実装を開始しました。具体的には、複素数データを処理するための畳み込み層を実装しました。また、OpenCL環境における複素数マスク付きアテンションモジュールの順伝播および逆伝播の実装についても検討しました。本日はこの作業をさらに進めます。
複素数マスク付きアテンションオブジェクト
前回の記事では、複素数領域におけるマスク付きアテンション機構の順伝播および逆伝播アルゴリズムを実装するMaskAttentionComplexカーネルとMaskAttentionGradientsComplexカーネルについて検討しました。本日はこの作業をさらに進めます。今回は、メインアプリケーション側でマスク付きアテンションモジュールを構成します。そのため、新たにCNeuronComplexMVMHMaskAttentionオブジェクトを作成します。このオブジェクトの構造は次のとおりです。
class CNeuronComplexMVMHMaskAttention : public CNeuronBaseOCL { protected: uint iWindow; uint iWindowKey; uint iHeads; uint iUnits; uint iVariables; //--- CNeuronComplexConvOCL cQKV; CNeuronBaseOCL cQ; CNeuronBaseOCL cKV; CNeuronConvOCL cMask; CNeuronBaseOCL cMHAttentionOut; CNeuronComplexConvOCL cPooling; CNeuronBaseOCL cResidual; CNeuronComplexConvOCL cFeedForward[2]; //--- virtual bool AttentionOut(void); virtual bool AttentionInsideGradients(void); virtual bool SumAndNormilize(CBufferFloat *tensor1, CBufferFloat *tensor2, CBufferFloat *out, int dimension, bool normilize = true, int shift_in1 = 0, int shift_in2 = 0, int shift_out = 0, float multiplyer = 0.5f) override; //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL) override; virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) override; virtual bool calcInputGradients(CNeuronBaseOCL *prevLayer) override; public: CNeuronComplexMVMHMaskAttention(void) {}; ~CNeuronComplexMVMHMaskAttention(void) {}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint window_key, uint heads, uint units_count, uint variables, ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual int Type(void) const { return defNeuronComplexMVMHMaskAttention; } //--- virtual bool Save(int const file_handle) override; virtual bool Load(int const file_handle) override; //--- virtual bool WeightsUpdate(CNeuronBaseOCL *source, float tau) override; virtual void SetOpenCL(COpenCLMy *obj) override; };
ご覧のとおり、この新しいオブジェクトの構造は、これまでライブラリ内で複数のバリエーションを実装してきたアテンションモジュールとほぼ同様の、標準的なものです。ただし、複素数データを使用することに起因する重要な違いが一つあります。本オブジェクトは、あらかじめ複素数形式へ変換された入力データを受け取るように設計されているため、データ変換の処理を省略できます。さらに、Query、Key、Valueの表現を生成するために、前回実装した複素数データ処理用の畳み込み層を利用します。それでは、実装を順を追って見ていきます。
すべての内部オブジェクトはクラスメンバーとして宣言されるため、クラスのコンストラクタとデストラクタを空のままにしておくことができます。これらの宣言済みおよび継承されたオブジェクトの初期化は、Initメソッド内でおこなわれます。このメソッドは、作成するオブジェクトのアーキテクチャを一意に定義する一連の定数を受け取ります。これらの定数の構成は、この種のオブジェクトとしては一般的なものです。
bool CNeuronComplexMVMHMaskAttention::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint window_key, uint heads, uint units_count, uint variables, ENUM_OPTIMIZATION optimization_type, uint batch) { //--- if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, 2 * window * units_count * variables, optimization_type, batch)) return false;
まず、このメソッドの冒頭では、親クラスの対応するメソッドを呼び出します。このメソッドには、継承されたオブジェクトおよびインターフェースの初期化処理がすでに実装されています。その後、外部プログラムから受け取ったモデルアーキテクチャのパラメータを内部変数に保存します。
iWindow = window; iWindowKey = MathMax(window_key, 1); iUnits = units_count; iHeads = MathMax(heads, 1); iVariables = variables;
次に、新たに宣言したオブジェクトを初期化します。最初に初期化するのは、マスク生成モジュールです。
ここで注目すべき点として、フレームワークの著者らは、入力データの学習可能な射影に基づくマスク生成モジュールを提案しています。さらに、マルチヘッドアテンション機構を採用しており、それぞれのアテンションヘッドが固有のマスクを使用します。
もう一つ重要な点は、アテンションが周波数スペクトル全体に対して適用されるわけではないことです。CATCHフレームワークでは、同一の周波数セグメントに対応するパッチについて、異なる単変量系列間でのみアテンションを適用します。
当然ながら、マスク生成モジュールの出力は、各チャネルの影響度を表す実数値の確率係数で構成される必要があります。
これらの要件を満たすため、本実装では、複素数ウィンドウに対応するためにカーネルサイズを2倍にした標準的な畳み込み層を使用し、フィルタ数は単一の系列要素に対するマスクベクトルの次元数と同じに設定しています。
uint index = 0; if(!cMask.Init(0, index, OpenCL, 2 * iWindow, 2 * iWindow, iVariables * iHeads, iUnits, iVariables, optimization, iBatch)) return false; cMask.SetActivationFunction(SIGMOID); CBufferFloat *temp = cMask.GetWeightsConv(); if(!temp || !temp.Fill(0)) return false;
出力値が要求された範囲内に収まるようにするため、シグモイド活性化関数を使用します。
また、初期化時には、学習可能なパラメータ行列をすべてゼロで初期化している点にも注意してください。これにより、すべての要素は影響マスク値0.5という等しい状態から学習を開始します。その後の学習過程で重みが更新され、チャネル間の依存関係を表すマスクが徐々に最適化されていきます。
次に、Query、Key、Valueの表現を生成するモジュールを初期化します。ここでは、入力と出力の両方が複素数データであるため、複素数対応の畳み込み層を使用します。
index++; if(!cQKV.Init(0, index, OpenCL, iWindow, iWindow, 3 * iWindowKey * iHeads, iUnits, iVariables, optimization, iBatch)) return false; cQKV.SetActivationFunction(None);
その後、3つの表現をまとめて格納したテンソルを2つに分割し、Queryを独立した行列として取り出します。この処理を実現するため、追加で2つのオブジェクトを初期化します。これらの処理が完了すると、すべての初期化が正常に終了したかどうかを示す真偽値を返して、メソッドを終了します。
index++; if(!cQ.Init(0, index, OpenCL, 2 * iWindowKey * iHeads * iVariables * iUnits, optimization, iBatch)) return false; cQ.SetActivationFunction(None); index++; if(!cKV.Init(0, index, OpenCL, 2 * cQ.Neurons(), optimization, iBatch)) return false; cKV.SetActivationFunction(None);
同じ段階で、マルチヘッドアテンションの結果を格納する役割を担うオブジェクトを初期化します。
index++; if(!cMHAttentionOut.Init(0, index, OpenCL, cQ.Neurons(), optimization, iBatch)) return false; cMHAttentionOut.SetActivationFunction(None);
次に、アテンション出力の次元を削減するために、複素数値畳み込み層を追加します。
index++; if(!cPooling.Init(0, index, OpenCL, iWindowKey * iHeads, iWindowKey * iHeads, iWindow, iUnits, iVariables, optimization, iBatch)) return false; cPooling.SetActivationFunction(None);
従来のTransformerアーキテクチャと同様に、残差接続を保持するためのレイヤーを追加します。
index++; if(!cResidual.Init(0, index, OpenCL, cPooling.Neurons(), optimization, iBatch)) return false; cResidual.SetActivationFunction(None);
FeedForwardモジュールの2つの複素数値畳み込み層が続きます。
index++; if(!cFeedForward[0].Init(0, index, OpenCL, iWindow, iWindow, 4 * iWindow, iUnits, iVariables, optimization, iBatch)) return false; cFeedForward[0].SetActivationFunction(LReLU); index++; if(!cFeedForward[1].Init(0, index, OpenCL, 4 * iWindow, 4 * iWindow, iWindow, iUnits, iVariables, optimization, iBatch)) return false; cFeedForward[1].SetActivationFunction(None); SetActivationFunction(None); //--- return true; }
最後に、オブジェクトの初期化手順を完了し、操作の成功を示すブール値を呼び出し元のプログラムに返します。
次のステップは、feedForwardメソッドの中に順伝播アルゴリズムを構築することです。
bool CNeuronComplexMVMHMaskAttention::feedForward(CNeuronBaseOCL *NeuronOCL) { if(!NeuronOCL) return false;
このメソッドは入力データオブジェクトへのポインタを受け取り、その有効性を即座に検証します。
学習の安定性を向上させるため、入力データは正規化されます。
if(!NeuronOCL.SwapOutputs()) return false; if(!SumAndNormilize(NeuronOCL.getPrevOutput(), NeuronOCL.getPrevOutput(), NeuronOCL.getOutput(), iWindow, true, 0, 0, 0, 1)) return false;
入力データは複素数値で構成されている点に注意する必要があります。そのため、複素数値演算をサポートするために、加算および正規化のメソッドを再定義しました。完全な実装については、添付のソースコードを参照してください。
正規化処理の後、入力データはマスキングテンソルおよびQuery、Key、Valueの各エンティティを生成するために使用されます。
if(!cMask.FeedForward(NeuronOCL)) return false; if(!cQKV.FeedForward(NeuronOCL)) return false;
生成されたエンティティを結合したテンソルを、2つの個別のテンソルに分割します。
if(!NeuronOCL.SwapOutputs()) return false; if(!DeConcat(cQ.getOutput(), cKV.getOutput(), cQKV.getOutput(), 2 * iWindowKey * iHeads, 4 * iWindowKey * iHeads, iUnits * iVariables)) return false;
準備されたすべてのデータは、その後、マスク付きアテンションのフォワードパスカーネルに渡されます。この処理は、AttentionOutという専用メソッド内で実行されます。
if(!AttentionOut()) return false;
次に、マルチヘッドアテンションの出力結果の次元数を削減し、その出力を元の入力データと加算して、残差接続を構築します。
if(!cPooling.FeedForward(cMHAttentionOut.AsObject())) return false; if(!SumAndNormilize(NeuronOCL.getOutput(), cPooling.getOutput(), cResidual.getOutput(), iWindow, true, 0, 0, 0, 1)) return false;
その後、データはFeedForwardモジュール内の2つの畳み込み層を通過します。
if(!cFeedForward[0].FeedForward(cResidual.AsObject())) return false; if(!cFeedForward[1].FeedForward(cFeedForward[0].AsObject())) return false;
再度、残差接続を追加します。最終的な出力は、モデル内の他のニューラル層とのデータ交換に使用される、継承されたバッファに保存されます。
if(!SumAndNormilize(cResidual.getOutput(), cFeedForward[1].getOutput(), getOutput(), iWindow, true, 0, 0, 0, 1)) return false; //--- return true; }
メソッドは処理の論理結果を呼び出し元に返して終了します。
ここで、マルチヘッドマスク付きアテンションカーネルMaskAttentionComplexの実行をキューに登録する役割を担うAttentionOutメソッドについて、いくつか説明します。近年の研究では、OpenCLカーネルを実行キューへ投入する仕組みについては、あまり詳しく説明してきませんでした。これは理解できることです。カーネルをキューに登録する手順は比較的標準的であり、同じ処理を繰り返し説明する必要はありません。しかし今回は、CATCHフレームワークに関連する重要な点があります。
前述したように、CATCHフレームワークの著者らは、異なる単変量系列間において、同一の周波数範囲に属する要素同士で厳密にマスク付きアテンションを実行することを提案しています。簡単に言えば、異なる単変量系列から同一の周波数パッチを取り出し、それらの相互依存関係を解析します。これにより、周波数帯域ごとに異なる単変量系列間の関係を独立して抽出でき、長期的なトレンドと短期的な変動を個別に捉えることが可能になります。しかし、この特性は、統一的なマスク付きアテンションカーネルを設計する際には明示的に考慮されていませんでした。
それでも、カーネル実行キューで使用するタスク空間を制御することで、必要な動作を実現できます。まず、オブジェクトが受け取る入力データの次元を確認します。ここでは、多変量系列から分割されたデータを扱っており、これは3次元テンソル{Variable, Segment, Dimension}として表現できます。さらに、マルチヘッドアテンション用のエンティティを構築する際、各出力は4次元テンソル{Variable, Segment, Head, Dimension}として表現できます。
重要なのは、マルチヘッドアテンション実装では、各アテンションヘッドを独立して処理している点です。したがって、SegmentとHeadの次元を統合し、各セグメントを独立したアテンションヘッドとして扱うことで、異なる単変量系列間における同一セグメント同士の依存関係を解析することができます。これはCATCHフレームワークを正しく実装するために必要な動作そのものです。
AttentionOutメソッドは、パラメータを受け取りません。このメソッドの制御ブロックでは、OpenCLコンテキスト管理オブジェクトへのポインタが有効であるかどうかのみを確認します。
bool CNeuronComplexMVMHMaskAttention::AttentionOut(void) { if(!OpenCL) return false;
次に、カーネル実行空間を定義する配列を設定します。前述したように、シーケンス次元にはデータセット内の単変量系列の数を設定します。ヘッド数については、設定されたアテンションヘッド数と各系列内のセグメント数の積を使用します。
uint global_work_offset[3] = {0}; uint global_work_size[3] = {iVariables/*Q units*/, iVariables/*K units*/, iHeads * iUnits/*Heads*/}; uint local_work_size[3] = {1, iVariables, 1};
その後、タスク空間の第2次元に沿って実行スレッドをワークグループにグループ化します。
続いて、必要なデータバッファへのポインタをカーネル引数として渡します。
ResetLastError(); int kernel = def_k_MaskAttentionComplex; if(!OpenCL.SetArgumentBuffer(kernel, def_k_maskattcom_q, cQ.getOutputIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(kernel, def_k_maskattcom_kv, cKV.getOutputIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(kernel, def_k_maskattcom_scores, cMask.getPrevOutIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(kernel, def_k_maskattcom_out, cMHAttentionOut.getOutputIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(kernel, def_k_maskattcom_masks, cMask.getOutputIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; }
この時点で、アテンション係数行列がチャネルマスキング行列と同じ次元を持つという特性を活用します。これにより、アテンション係数を一時的に保存するための追加バッファを確保する必要がなくなります。代わりに、マスク生成モジュールで使用されていないバッファを再利用し、この目的に使用します。
その後、残りの必要なパラメータをカーネルに渡します。
if(!OpenCL.SetArgument(kernel, def_k_maskattcom_dimension, (int)iWindowKey)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(kernel, def_k_maskattcom_heads_kv, (int)(iHeads * iUnits))) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; }
また、KeyおよびValue表現の両方について、アテンションヘッド数は、ユーザーが定義したヘッド数とシーケンス長の積として再び設定している点にも注意する必要があります。
最後に、カーネルを実行キューに登録します。
if(!OpenCL.Execute(kernel, 3, global_work_offset, global_work_size, local_work_size)) { printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError()); return false; } //--- return true; }
メソッドは、呼び出し元のプログラムに論理結果を返すことによって終了します。
フォワードパスのアルゴリズムの実装が完了した後、次にバックプロパゲーション処理の構築に進みます。最初のステップとして、モデルの最終出力への各内部コンポーネントの寄与度に応じて、誤差勾配をすべての内部コンポーネントへ分配する役割を持つメソッドcalcInputGradientsの実装を行います。
bool CNeuronComplexMVMHMaskAttention::calcInputGradients(CNeuronBaseOCL *prevLayer) { if(!prevLayer) return false;
このメソッドは、フォワードパス時に使用されたものと同じ入力データオブジェクトを受け取ります。ただし、今回は対応する誤差勾配を伝播するために使用されます。まず、ポインタの有効性を確認します。
この段階では、オブジェクトの外部インターフェースバッファには、後続の層から受け取った誤差勾配が格納されています。これらの値には、まだ活性化関数の微分は適用されていません。これは意図的な設計です。初期化時に活性化関数の適用を意図的に省略しているためです。これにより、内部オブジェクトごとに使用する活性化関数を柔軟に選択できるようになります。ここで、FeedForwardモジュール内の最後の畳み込み層における活性化関数の微分を適用し、補正された勾配を対応する経路へ伝播します。
if(!DeActivation(cFeedForward[1].getOutput(), cFeedForward[1].getGradient(), Gradient, cFeedForward[1].Activation())) return false;
次に、FeedForwardモジュール内の各層を通じて勾配を逆方向へ伝播し、アテンションブロック後の残差接続レベルまで処理を進めます。
if(!cFeedForward[0].calcHiddenGradients(cFeedForward[1].AsObject())) return false; if(!cResidual.calcHiddenGradients(cFeedForward[0].AsObject())) return false;
残差接続オブジェクトについても、活性化関数が存在しないものと仮定します。そのため、アテンション出力スケーリング層に適用された活性化関数の微分を用いて、勾配補正処理を再度実行します。
if(!DeActivation(cPooling.getOutput(), cPooling.getGradient(), cResidual.getGradient(), cPooling.Activation()) || !DeActivation(cPooling.getOutput(), cPooling.getPrevOutput(), Gradient, cPooling.Activation()) || !SumAndNormilize(cPooling.getGradient(), cPooling.getPrevOutput(), cPooling.getGradient(), iWindow, false, 0, 0, 0, 1)) return false;
ただし、ここで重要なのは、現時点で残差接続オブジェクトに格納されているものは、FeedForward経路を通過する勾配のみであるという点です。そのため、残差付きアテンション経路を通る勾配も考慮する必要があります。そこで、アテンション出力射影層の活性化関数の微分を用いて、外部インターフェースバッファに保存されている勾配を再度補正します。
なお、外部インターフェースバッファを再利用することは、データに対して二重の変換を行うことを意味しません。最初の処理では、補正された値はFeedForward最終層のバッファに保存され、一方でインターフェースバッファ自体には元の勾配値が保持されています。現在のステップでは、元のデータを変更することなく、射影層のバッファへ計算結果を書き込みます。この元のデータは、後ほど残差接続を通じて入力レベルへ勾配を逆伝播する際に再利用されます。
これらの補正処理の後、2つの情報経路から得られた勾配の寄与を加算します。その後、勾配はアテンションヘッド全体に分配されます。
if(!cMHAttentionOut.calcHiddenGradients(cPooling.AsObject())) return false; if(!AttentionInsideGradients()) return false;
続いて、アテンション機構内部を通じて勾配を伝播する役割を持つAttentionInsideGradientsメソッドを呼び出します。このメソッドの詳細については、ここでは説明を省略します。詳細な検討は読者に委ねます。完全な実装は添付資料に含まれています。ただし、注意すべき点として、タスク空間の構成およびカーネルスケジューリングパラメータは、フォワードパスで使用したものと一致している必要があります。
次の段階では、個々のアテンションコンポーネントから得られたすべての勾配寄与を、単一のテンソルへ統合します。
if(!Concat(cQ.getGradient(), cKV.getGradient(), cQKV.getGradient(), 2 * iWindowKey * iHeads, 4 * iWindowKey * iHeads, iUnits * iVariables)) return false;
その後、対応する活性化関数の微分を適用しながら、誤差を入力レベルまで逆伝播します。
if(!DeActivation(cQKV.getOutput(), cQKV.getPrevOutput(), cQKV.getGradient(), cQKV.Activation()) || !prevLayer.calcHiddenGradients(cQKV.AsObject())) return false;
しかし、これは1つの情報経路のみを表しています。ここでは、残差接続経路を通る勾配の寄与も加える必要があります。その際、入力データ処理層の活性化関数の微分を適用します。
このステップでは、FeedForwardモジュールのデータと外部インターフェースバッファのデータの両方を使用します。
if(!DeActivation(prevLayer.getOutput(),cResidual.getPrevOutput(),cResidual.getGradient(),prevLayer.Activation()) || !SumAndNormilize(prevLayer.getGradient(), cResidual.getPrevOutput(), cResidual.getPrevOutput(), iWindow, false, 0, 0, 0, 1)) return false; if(!DeActivation(prevLayer.getOutput(), cResidual.getGradient(), Gradient, prevLayer.Activation()) || !SumAndNormilize(cResidual.getGradient(), cResidual.getPrevOutput(), cResidual.getPrevOutput(), iWindow, false, 0, 0, 0, 1)) return false;
また、入力データはマスキング行列の生成にも使用されていたことを思い出す必要があります。そのため、この計算経路に関連する勾配の寄与も追加で考慮します。
if(!DeActivation(cMask.getOutput(), cMask.getGradient(), cMask.getGradient(), cMask.Activation()) || !prevLayer.calcHiddenGradients(cMask.AsObject()) || !SumAndNormilize(prevLayer.getGradient(), cResidual.getPrevOutput(), prevLayer.getGradient(), iWindow, false, 0, 0, 0, 1)) return false; //--- return true; }
この時点で、入力レベルにおけるすべての情報経路からの完全な勾配信号が蓄積されました。これにより、メソッドは論理的なステータスを呼び出し元のプログラムへ返して終了できます。
パラメータ最適化メソッドについては各自で確認してください。そのロジックは比較的単純であり、主に内部サブモジュールの対応するメソッドを呼び出す構成になっています。完全な実装は、オブジェクト全体およびすべてのメソッドの完全なコードとともに添付資料に含まれています。次の作業段階へ進みます。
CATCHフレームワークの構築
ここまでの段階で、個別のオブジェクトを開発するという多くの準備作業が完了したと言えます。次に、それらを統合し、CATCHフレームワーク全体の統一された構造を構築します。フレームワークの実行ロジックはCNeuronCATCHオブジェクト内に実装されています。その構造を以下に示します。
class CNeuronCATCH : public CNeuronTransposeOCL { protected: CNeuronTransposeOCL cTranspose; CNeuronBaseOCL caFreqIn[2]; CNeuronBaseOCL cFreqConcat; CNeuronComplexConvOCL caProjection[2]; CNeuronComplexMVMHMaskAttention caChannelFusion[2]; CNeuronComplexConvOCL caLinearHead[2]; CNeuronBaseOCL caFreqOut[2]; //--- virtual bool FFT(CBufferFloat *inp_re, CBufferFloat *inp_im, CBufferFloat *out_re, CBufferFloat *out_im, uint variables, bool reverse = false); //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL) override; virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) override; virtual bool calcInputGradients(CNeuronBaseOCL *prevLayer) override; public: CNeuronCATCH(void) {}; ~CNeuronCATCH(void) {}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint time_step, uint variables, uint window, uint step, uint window_key, uint heads, ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual int Type(void) override const { return defNeuronCATCH; } //--- virtual bool Save(int const file_handle) override; virtual bool Load(int const file_handle) override; //--- virtual bool WeightsUpdate(CNeuronBaseOCL *source, float tau) override; virtual void SetOpenCL(COpenCLMy *obj) override; };
提示した構造を見ると、フレームワークの複雑な処理アルゴリズムの各部分を担当する、多数の内部コンポーネントが存在することが分かります。それぞれの役割については、クラスメソッドの実装を進める中で順次説明していきます。現時点で注目すべき点は、すべてのコンポーネントがクラスメンバーとしてあらかじめ宣言されていることです。これにより、コンストラクタおよびデストラクタは空のままにすることができます。オブジェクトの初期化はInitメソッドで実行されます。
bool CNeuronCATCH::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint time_step, uint variables, uint window, uint step, uint window_key, uint heads, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronTransposeOCL::Init(numOutputs, myIndex, open_cl, variables, time_step, optimization_type, batch)) return false;
このメソッドは、初期化されるオブジェクトのアーキテクチャを一意に定義する一連の定数を受け取ります。これらのパラメータの一部は入力データの次元数を定義し、その他は内部情報フローの構造を記述します。たとえば、time_stepとvariablesは、多変量時系列データに含まれる時間ステップ数および単変量系列の数を定義します。windowとstepのパラメータは、周波数スペクトルを分割する方法を決定します。一方、window_keyとheadsは、アテンション機構の中核となるパラメータを表します。
メソッド本体では、まず親クラスに対応するメソッドを呼び出します。この場合、親クラスはデータ転置オブジェクトであり、この選択は意図的なものです。入力データは、各行が特定の時間ステップにおける環境の状態ベクトルを表す行列形式の多変量時系列として与えられることを想定しています。しかし、CATCHフレームワークは、個々の単変量系列を周波数領域表現へ変換した表現を用いて動作します。そのため、利便性を考慮し、まず入力テンソルを転置し、その後で元の表現へ復元します。後者の処理は親クラスによって実行され、それに対応する設定が渡されるパラメータに反映されています。
時系列データを周波数領域へ変換するために、高速フーリエ変換(FFT)を使用します。ただし、このアルゴリズムでは系列長が2のべき乗である必要があります。この制約を満たすために、任意の系列にゼロパディングを追加して長さを拡張できます。この操作は変換結果に影響を与えません。次のステップでは、条件を満たす最も近い有効な系列長を決定します。
//--- Calculate FFT size int power = int(MathLog(time_step) / M_LN2); if(power <= 0) return false; if(MathPow(2, power) != time_step) power++; uint FreqUinits = uint(MathPow(2, power));
次に、指定されたセグメンテーションパラメータに従ってセグメント数を算出します。
if(window <= 0 || step <= 0) return false; int Segments = int((FreqUinits - int(window) + step - 1) / step); if(Segments <= 0) return false;
この時点で準備段階は完了し、内部コンポーネントの初期化に進みます。1つ目は、入力データのための転置層です。その初期化パラメータは単純明快で、特別な説明は不要です。
uint index = 0; if(!cTranspose.Init(0, index, OpenCL, time_step, variables, optimization, iBatch)) return false;
次に、フーリエ分解後の周波数スペクトルの実数部と虚数部を格納するための2つのデータバッファを作成します。
for(uint i = 0; i < caFreqIn.Size(); i++) { index++; if(!caFreqIn[i].Init(0, index, OpenCL, FreqUinits * variables, optimization, iBatch)) return false; caFreqIn[i].SetActivationFunction(None); }
次に、周波数領域表現のための連結モジュールを追加します。
index++; if(!cFreqConcat.Init(0, index, OpenCL, 2 * FreqUinits * variables, optimization, iBatch)) return false; cFreqConcat.SetActivationFunction(None);
スペクトルセグメンテーションには、適切なパラメータで構成された複素数値畳み込み層を使用します。
index++; if(!caProjection[0].Init(0, index, OpenCL, window, step, 2 * window, Segments, variables, optimization, iBatch)) return false; caProjection[0].SetActivationFunction(LReLU);
2番目の畳み込み層によって、スペクトルパッチの埋め込み構造が完成します。
index++; if(!caProjection[1].Init(0, index, OpenCL,2*window,2*window,window_key, Segments, variables, optimization, iBatch)) return false; caProjection[1].SetActivationFunction(TANH);
個々の単変量系列の周波数スペクトル間における相互依存関係の解析は、2つの連続したアテンションモジュールを用いて実行されます。最初のモジュールは、生成されたパッチ埋め込みに対して直接処理をおこないます。
index++; if(!caChannelFusion[0].Init(0, index, OpenCL,window_key,window_key,heads,Segments, variables, optimization, iBatch)) return false;
2番目のチャネル間マスク付きアテンション層のアーキテクチャは、セグメント数に依存します。セグメント数が2で割り切れる場合、高次の依存関係を抽出するために、セグメントをペア単位で結合する処理が実行されます。
index++; if(Segments % 2 == 0) { if(!caChannelFusion[1].Init(0, index, OpenCL, 2 * window_key, window_key, heads, Segments / 2, variables, optimization, iBatch)) return false; } else if(!caChannelFusion[1].Init(0, index, OpenCL, window_key, window_key, heads, Segments, variables, optimization, iBatch)) return false;
それ以外の場合、アーキテクチャは前のアテンション層と同一のまま維持されます。
その後、アテンションモジュールによって生成されたテンソルの次元数は、2つの連続した射影畳み込み層を使用して、元の周波数スペクトルの長さまで再び削減されます。
index++; if(!caLinearHead[0].Init(0, index, OpenCL, window_key, window_key, window, Segments, variables, optimization, iBatch)) return false; caLinearHead[0].SetActivationFunction(LReLU); index++; if(!caLinearHead[1].Init(0, index, OpenCL, window * Segments, window * Segments, FreqUinits, variables, 1, optimization, iBatch)) return false; caLinearHead[ 1 ].SetActivationFunction(None);
最初の層ではセグメント次元の調整を行い、2番目の層では単変量系列の長さを補正します。
また、逆フーリエ変換を適用する前に、周波数スペクトルの実数成分と虚数成分を分離する役割を持つ2つの追加コンポーネントを導入します。
for(uint i = 0; i < caFreqOut.Size(); i++) { index++; if(!caFreqOut[i].Init(0, index, OpenCL, FreqUinits * variables, optimization, iBatch)) return false; caFreqOut[i].SetActivationFunction(None); } //--- return true; }
この段階で、すべての内部コンポーネントの初期化が完了し、メソッドは正常終了を示すブール値を返して終了します。
次に、feedForwardメソッド内でフォワードパス処理を実装します。前述したように、このメソッドは多変量時系列テンソルを含む入力データオブジェクトへのポインタを受け取ります。
bool CNeuronCATCH::feedForward(CNeuronBaseOCL *NeuronOCL) { if(!cTranspose.FeedForward(NeuronOCL)) return false;
入力データは、単変量系列として処理しやすくするために、まず転置されます。その後、高速フーリエ変換を用いて周波数領域へ変換されます。
if(!FFT(cTranspose.getOutput(), NULL, caFreqIn[0].getOutput(), caFreqIn[1].getOutput(), iCount, false)) return false;
得られた周波数表現は、統合された1つのテンソルへ結合されます。
if(!Concat(caFreqIn[0].getOutput(), caFreqIn[1].getOutput(), cFreqConcat.getOutput(), 1, 1, caFreqIn[0].Neurons())) return false;
次に、2つの射影畳み込み層を用いて、スペクトルパッチ化および埋め込み処理を実行します。
CNeuronBaseOCL *neuron = cFreqConcat.AsObject(); for(uint i = 0; i < caProjection.Size(); i++) { if(!caProjection[i].FeedForward(neuron)) return false; neuron = caProjection[i].AsObject(); }
続いて、チャネル間マスク付きアテンションモジュールを用いて相互依存関係を解析します。
for(uint i = 0; i < caChannelFusion.Size(); i++) { if(!caChannelFusion[i].FeedForward(neuron)) return false; neuron = caChannelFusion[i].AsObject(); }
その後、出力は元の周波数スペクトルの次元数へ射影されます。
for(uint i = 0; i < caLinearHead.Size(); i++) { if(!caLinearHead[i].FeedForward(neuron)) return false; neuron = caLinearHead[i].AsObject(); }
次に、周波数表現を実数成分と虚数成分に分割します。
if(!DeConcat(caFreqOut[0].getOutput(), caFreqOut[1].getOutput(), neuron.getOutput(), 1, 1, caFreqOut[0].Neurons())) return false; if(!FFT(caFreqOut[0].getOutput(), caFreqOut[1].getOutput(), caFreqOut[0].getPrevOutput(), caFreqOut[1].getPrevOutput(), iCount, true)) return false;
その後、逆フーリエ変換を適用し、データを周波数領域から時間領域の系列表現へ戻します。ただし、逆高速フーリエ変換(IFFT)は、長さが2のべき乗となる系列を生成する点に注意が必要です。その長さは元の系列長と異なる可能性があります。そのため、余分な値はテンソルの分割処理によって削除します。
if(!DeConcat(caFreqOut[0].getOutput(), caFreqOut[1].getOutput(), caFreqOut[0].getPrevOutput(), iWindow, caFreqOut[0].Neurons() / iCount - iWindow, iCount)) return false;
次に、転置された入力データからの残差接続を結果に加算し、正規化を行います。最後に、データを元の時系列表現へ戻します。
if(!SumAndNormilize(caFreqOut[0].getOutput(),cTranspose.getOutput(),caFreqOut[0].getOutput(),iWindow,true,0,0,0,1)) return false; //--- return CNeuronTransposeOCL::feedForward(caFreqOut[0].AsObject()); }
これによりフォワードパスの計算が完了し、メソッドは処理結果を示すブール値を呼び出し元へ返します。
次の開発段階では、新しいオブジェクトに対するバックワードパス処理を実装します。特に、最終出力への各コンポーネントの寄与度に応じて誤差勾配を分配する役割を持つcalcInputGradientsメソッドが重要になります。
bool CNeuronCATCH::calcInputGradients(CNeuronBaseOCL *prevLayer) { if(!prevLayer) return false;
このメソッドは入力データオブジェクトへのポインタを受け取り、まず有効性を検証します。このような検証の必要性については、これまでにも繰り返し説明してきました。
次に、後続層から受け取った勾配を、単変量系列表現へ転置します。
if(!CNeuronTransposeOCL::calcInputGradients(caFreqOut[1].AsObject())) return false; if(!SumAndNormilize(caFreqOut[1].getGradient(),caFreqOut[1].getGradient(),cTranspose.getPrevOutput(), iWindow,false,0,0,0,0.5f)) return false;
得られた値は、残差接続経路を表す入力転置オブジェクトの利用可能なバッファへ直ちにコピーされます。
ここで、逆フーリエ変換の出力系列が期待される系列長を超える可能性があることを思い出す必要があります。フォワードパスの処理では、余分な値を破棄しました。ここで注意すべき点は、フォワードパスを実行する際、解析対象の時系列データにゼロ値を追加して、必要なサイズまで拡張していたことです。そのため、逆フーリエ変換の結果において破棄される領域には、同様にゼロ値が得られることが期待されます。したがって、正しい学習動作を実現するために、この破棄対象領域に対応する勾配には、先に取得した値の符号を反転したものを設定します。
if((caFreqOut[0].Neurons() - iWindow) > 0) if(!SumAndNormilize(caFreqOut[1].getOutput(), caFreqOut[1].getOutput(), caFreqOut[1].getOutput(), 1, false, 0, 0, 0, -0.5f)) return false;
次に、両方のブロックから得られた勾配を1つのテンソルへ結合します。
if(!Concat(caFreqOut[1].getGradient(), caFreqOut[1].getOutput(), caFreqOut[0].getGradient(), iWindow, caFreqOut[0].Neurons() - iWindow, iCount)) return false;
この時点で、再構成された信号の実数成分に対する勾配が得られます。しかし、虚数成分についても考慮する必要があります。逆フーリエ変換は周波数表現から実数値の時系列を再構成します。時系列自体は実数値で表現され、その虚数成分はゼロです。そのため、虚数成分の誤差勾配を定義する方法は、破棄された実数成分の場合と同じ考え方に従います。つまり、先に得られた結果の符号を反転させます。
if(!SumAndNormilize(caFreqOut[1].getPrevOutput(), caFreqOut[1].getPrevOutput(), caFreqOut[1].getGradient(), 1, false, 0, 0, 0, -0.5f)) return false;
その後、高速フーリエ変換を用いて勾配を周波数領域へ変換します。
if(!FFT(caFreqOut[0].getGradient(), caFreqOut[1].getGradient(), caFreqOut[0].getOutput(), caFreqOut[1].getOutput(), iCount, false)) return false;
得られた値は、実数成分と虚数成分の両方を含む複素勾配を表す統合テンソルへ結合されます。
if(!Concat(caFreqOut[0].getOutput(), caFreqOut[1].getOutput(), caLinearHead[1].getGradient(), 1, 1, caFreqOut[0].Neurons())) return false;
次に、勾配をすべての内部オブジェクトへ順次伝播します。まず、アテンションモジュールの最終射影層を通過させます。
if(!caLinearHead[0].calcHiddenGradients(caLinearHead[1].AsObject())) return false;
続いて、アテンションモジュール自体を通り、埋め込み畳み込み層まで逆伝播します。
CObject *neuron = caLinearHead[0].AsObject(); for(int i = int(caChannelFusion.Size()) - 1; i >= 0; i--) { if(!caChannelFusion[i].calcHiddenGradients(neuron)) return false; neuron = caChannelFusion[i].AsObject(); }
この処理は、入力データの周波数領域表現を結合したテンソルへ到達するまで続きます。
for(int i = int(caProjection.Size()) - 1; i >= 0; i--) { if(!caProjection[i].calcHiddenGradients(neuron)) return false; neuron = caProjection[i].AsObject(); } //--- if(!cFreqConcat.calcHiddenGradients(neuron)) return false;
この段階で、結果は実数成分と虚数成分へ分割されます。
if(!DeConcat(caFreqIn[0].getGradient(), caFreqIn[1].getGradient(), cFreqConcat.getGradient(), 1, 1, caFreqIn[0].Neurons())) return false;
その後、逆フーリエ変換を適用し、勾配を時間領域表現へ戻します。
if(!FFT(caFreqIn[0].getGradient(), caFreqIn[1].getGradient(), caFreqIn[0].getPrevOutput(), caFreqIn[1].getPrevOutput(), iCount, false)) return false;
有効な入力データに対応する部分のみを保持します。
if(!DeConcat(cTranspose.getGradient(), caFreqIn[0].getGradient(), caFreqIn[0].getPrevOutput(), iWindow, caFreqIn[0].Neurons() / iCount - iWindow, iCount)) return false; //--- if(!SumAndNormilize(cTranspose.getGradient(),cTranspose.getPrevOutput(),cTranspose.getGradient(),iWindow, false,0,0,0,1.0f)) return false;
この結果を、以前保存した残差接続からの勾配へ加算します。
最後に、勾配を元の入力表現へ戻すために転置処理をおこない、必要に応じて入力層の活性化関数の微分による補正を適用します。
if(!prevLayer.calcHiddenGradients(cTranspose.AsObject())) return false; if(prevLayer.Activation() != None) { if(!DeActivation(prevLayer.getOutput(), prevLayer.getGradient(), prevLayer.getGradient(), prevLayer.Activation())) return false; } //--- return true; }
実行された処理の論理結果を呼び出し元プログラムに返し、メソッドの実行を完了します。
これにより、CATCHフレームワークで提案された手法の実装説明を完了します。説明したすべてのコンポーネントおよびメソッドの完全なソースコードは、添付資料に含まれています。
モデルアーキテクチャ
提案手法の実装アルゴリズムについて確認した後、次に学習可能なモデルのアーキテクチャについて簡単に説明します。前回の研究と同様に、今回は3つのモデルを学習します。それらは、環境状態を表現するためのエンコーダ(Encoder)、行動を生成するActor、そして次の方向性変化の確率を予測する予測モデルです。CATCHフレームワークは、観測された環境状態の表現を担うエンコーダに組み込まれています。フレームワーク全体が単一のオブジェクト内にカプセル化されているため、結果として得られるモデルアーキテクチャは視覚的には比較的単純な構成になっています。
bool CreateDescriptions(CArrayObj *&encoder, CArrayObj *&actor, CArrayObj *&probability) { //--- CLayerDescription *descr; //--- if(!encoder) { encoder = new CArrayObj(); if(!encoder) return false; } if(!actor) { actor = new CArrayObj(); if(!actor) return false; } if(!probability) { probability = new CArrayObj(); if(!probability) return false; } //--- Encoder encoder.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; int prev_count = descr.count = (HistoryBars * BarDescr); descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; }
通常どおり、最初のコンポーネントは生の入力データをエンコードするための層であり、その後にバッチ正規化層が続きます。この段階では、多変量時系列データに対する初期的な前処理をおこない、単変量系列間のスケールを比較可能な範囲に整えます。
//--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBatchNormOCL; descr.count = prev_count; descr.batch = 1e4; descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; }
次に、CATCHモジュールを適用します。ここでは、周波数スペクトルをステップ幅1で8個の要素に分割するセグメンテーションを使用します。これにより、入力データの周波数スペクトル全体にわたる相互依存関係を、より詳細に解析することが可能になります。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronCATCH; prev_count=descr.count = HistoryBars; { int temp[]={BarDescr,8,32,4}; // Variables, Frequency window, Key Size, Heads if(ArrayCopy(descr.windows, temp) < (int)temp.Size()) return false; } descr.step=1; int prev_out=descr.windows[0]; descr.batch = 1e4; descr.optimization=ADAM; descr.activation = None; if(!encoder.Add(descr)) { delete descr; return false; }
得られた表現は、その後、狭い範囲の正規化されたスケールへ再調整されます。
//--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; descr.count = HistoryBars; descr.window = BarDescr; descr.step = BarDescr; descr.window_out = BarDescr; descr.layers = 1; descr.activation = TANH; if(!encoder.Add(descr)) { delete descr; return false; }
最後に、逆正規化レイヤーを用いて元のデータ分布を復元します。
//--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronRevInDenormOCL; descr.count = HistoryBars * BarDescr; descr.layers = 1; descr.activation = None; if(!encoder.Add(descr)) { delete descr; return false; }
Actorおよび今後の価格変動方向の確率予測モデルのアーキテクチャは、前回の研究からほぼ変更されていません。読者の皆様には、これらの構成について独自に確認することを推奨します。完全なソースコードは添付ファイルに含まれています。同様に、環境との相互作用処理およびモデル学習用スクリプトについても、変更を加えることなく引き継いで使用しています。
テスト
CATCHフレームワークの手法をMQL5に実装し、学習可能なモデルへ統合するために、多くの作業をおこなってきました。ここで、決定的な段階である、実際の履歴データを用いた提案手法の有効性評価へ進みます。この段階では、実装の長所と短所の両方を評価し、さらなる最適化の可能性を判断することができます。
学習には、MetaTrader 5ストラテジーテスターで生成されたランダムエピソードを用いて構築したデータセットを使用しました。このデータセットは、2024年全期間のEURUSD M1履歴データを基にしています。
モデルの評価は、2025年1月から3月までの履歴データを用いて実施しました。実験条件の客観性を維持し、戦略性能を偏りなく評価するため、すべての実験パラメータは変更していません。この設定により、モデルが単に学習データを記憶するのではなく、新しい市場環境へ適応する能力を示していることを確認できます。
テスト結果を以下に示します。

テスト期間中、モデルは68回の取引を実行しました。そのうち32回が利益で決済され、勝率は47%をわずかに上回りました。一方で、利益となった取引1回あたりの平均利益は、損失となった取引1回あたりの平均損失のほぼ2倍に達しました。その結果、モデルはテスト期間全体で収益性を達成し、プロフィットファクターは1.72となりました。
結論
これら2本の記事では、多変量時系列における異常検出のための革新的な手法であるCATCHフレームワークの理論的基盤について検討しました。この手法は、フーリエ変換技術と周波数領域におけるパッチ化メカニズムを組み合わせたアプローチです。その主な利点は、時間領域のみで分析を行った場合には見落とされる可能性のある複雑なパターンを発見できる点にあります。
データを周波数領域で表現することで、市場動向に対するより深い洞察を得ることができます。周波数パッチ化メカニズムは分析に柔軟性をもたらし、観測対象となる環境の変化にモデルが適応することを可能にします。従来の手法とは異なり、CATCHは急激な価格変動や外れ値の検出だけに限定されず、データ内部に存在する潜在的な依存関係を識別する能力を持っています。
実践的な部分では、提案手法の独自実装をMQL5上で構築し、モデルの学習をおこない、実際の履歴データを用いて検証しました。結果は有望な可能性を示していますが、さらなる最適化に関してはいくつかの課題が残されています。
参照文献
記事で使用されたプログラム
| # | 名前 | 種類 | 詳細 |
|---|---|---|---|
| 1 | Research.mq5 | EA | サンプル収集用EA |
| 2 | ResearchRealORL.mq5 | EA | Real-ORL法を用いたサンプル収集用EA |
| 3 | Study.mq5 | EA | モデル学習用EA |
| 4 | Test.mq5 | EA | モデルテスト用EA |
| 5 | Trajectory.mqh | クラスライブラリ | システム状態とモデルアーキテクチャ記述構造 |
| 6 | NeuroNet.mqh | クラスライブラリ | ニューラルネットワークを作成するためのクラスのライブラリ |
| 7 | NeuroNet.cl | コードライブラリ | OpenCLプログラムコード |
MetaQuotes Ltdによってロシア語から翻訳されました。
元の記事: https://www.mql5.com/ru/articles/17675
警告: これらの資料についてのすべての権利はMetaQuotes Ltd.が保有しています。これらの資料の全部または一部の複製や再プリントは禁じられています。
この記事はサイトのユーザーによって執筆されたものであり、著者の個人的な見解を反映しています。MetaQuotes Ltdは、提示された情報の正確性や、記載されているソリューション、戦略、または推奨事項の使用によって生じたいかなる結果についても責任を負いません。
取引におけるニューラルネットワーク:適応的なエージェント行動のための階層的スキル発見(HiSSD)
コード、涙、そしてAlgo Forge
取引におけるニューラルネットワーク:適応型エージェント行動のための階層的スキル発見(最終章)
MQL5取引ツール(第24回):3Dカーブ、パンモード、ViewCubeナビゲーションによる奥行き感の向上
- 無料取引アプリ
- 8千を超えるシグナルをコピー
- 金融ニュースで金融マーケットを探索