
交易中的神经网络:节点-自适应图形表征(NAFS)
概述
近年来,图形表征学习在诸如节点聚类、链路预测、节点分类、图形分类、等各种应用场景中得到了广泛的运用。图形表征学习的目标是把图形信息编码成节点嵌入。传统的图形表征学习方法主要侧重于保存有关图形结构的信息。然而,这些方法面临两个主要限制:
- 浅层架构。图卷积网络(GCNs)采用多层来捕获深度结构信息,然增加层数往往会导致过度平滑,结局是无法区分节点嵌入。
- 可扩展性差。由于计算成本高、且内存消耗大,基于 GNN 的图形表征学习方法或许无法扩展到大幅图形。
论文《NAFS:图形表征学习的简单但难以击败的基线》的作者着手引入一种基于简单特征平滑、和自适应组合的新型图形表征方法来解决这些问题。节点-自适应特征平滑(NAFS)方法通过整合图形的结构信息和节点特征来生成卓越的节点嵌入。基于对不同节点展现出的高度变化“平滑速度”的观察,NAFS 使用低阶和高阶邻域信息自适应地平滑每个节点的特征。进而,特征融汇用来组合使用不同平滑算子提取的平滑特征。由于 NAFS 不需要训练,它显著降低了训练成本,并有效地扩展到大幅图形。
1. NAFS 算法
许多研究人员已提议在每个 GCN 层内把特征平滑和变换分离,以便实现可扩展的节点分类。具体而言,它们首先在预处理步骤中应用特征平滑操作,然后将处理后的特征输入到简单 MLP 中,以便生成最终的节点标签预测。
这样解耦的 GNNs 由两部分组成:特征平滑、和 MLP 训练。特征平滑阶段把结构化图形信息、与节点特征相结合,为后续 MLP 生成更多信息输入。在训练期间,MLP 仅从这些平滑特征中学习。
GNN 研究的另一条分支也把平滑和变换分离,但遵循不同的方式。原始节点特征首先被输入到 MLP 中以生成过渡嵌入。随后是针对这些嵌入应用个性化传播操作,从而获得最终预测。然而,这条 GNN 分支仍然需要在每个训练时期执行递归传播操作,这令其对于大规模图形来说是不切实际的。
捕获丰富结构化信息的最简单方法是堆叠多个 GNN 层。然而,GNN 模型中的重复特征平滑会导致节点嵌入无法区分 — 众所周知的过度平滑问题。
定量分析经验表明,节点程度在判定其最优平滑步骤方面扮演着重要角色。直观上,高度节点相比低度节点,应当经历更少的平滑步骤。
而在解耦的 GNNs 中应用特征平滑,可对大幅图形进行可扩展训练,但跨越所有节点不加区别的平滑,结局是嵌入不理想。具有不同结构属性的节点需要不同的平滑率。因此,节点-自适应特征平滑应当用来满足每个节点独特的平滑需求。
当按顺序应用时,𝐗l=Â𝐗l−1,平滑节点嵌入矩阵 𝐗l−1 随 l 增加而积累更深的结构信息。然后将多尺度节点嵌入矩阵 {𝐗0, 𝐗1, …, 𝐗K}(其中 K 是最大平滑步长)合并到一个统一的矩阵 Ẋ 之中,其中结合了局部和全局邻域信息。
NAFS 作者的分析表明,每个节点达到稳态的速率变化极大。因此,需要进行个性化的节点分析。为此,NAFS 引入了平滑权重的概念,计算则基于节点的局部与平滑特征向量之间的距离。这允许为每个节点单独定制平滑过程。
更有效的替代方法是用余弦相似度替换平滑矩阵 Â。节点的局部和平滑特征向量之间的余弦相似度越高,表明节点 vi 离均衡更远,并且 [Âk𝐗] 直观地包含大量最新信息。因此,对于节点 vi,拥有较高余弦相似度的平滑特征对其最终嵌入贡献应当更大。
不同的平滑运算符有效地充当不同的知识提取器。这就能够捕获各种尺度和维度的图形结构。为了达成这一点,特征融汇操作要用到多个知识提取器。这些提取器是在特征平滑过程内所用,以便生成各种平滑特征。
NAFS 无需任何训练即可生成节点嵌入,令其高效且可扩展。甚至,节点-自适应特征平滑策略允许捕捉深层结构信息。
作者对 NAFS 方法的可视化如下所示。
2. 利用 MQL5 实现
在涵盖了 NAFS 框架的理论层面之后,我们现在转到利用 MQL5 实际实现。在继续实际实现之前,我们清晰地概括一下框架的主要阶段。
- 构造多尺度节点表征矩阵。
- 基于节点的特征向量与其平滑表征之间的余弦相似性,计算平滑权重。
- 计算最终嵌入的加权平均值。
值得注意的是,其中一些操作可用我们函数库中现有功能来实现。例如,计算余弦相似度、以及计算加权平均值,能通过矩阵乘法有效地实现。Softmax 层能辅助判定平滑系数。
剩下的问题是构造多尺度节点表征矩阵。
2.1多尺度节点表征矩阵
为了构建多尺度节点表示矩阵,我们将取单个节点特征及其直接邻域相应特征的简单平均。多尺度行为是通过应用不同大小的平均窗口来达成的。
在我们的工作中,我们在 OpenCL 关联环境中实现主要计算。由此,矩阵构造过程也将委托给并行计算。为此目的,我们将在 OpenCL 程序 FeatureSmoothing 中创建一个新内核。
__kernel void FeatureSmoothing(__global const float *feature, __global float *outputs, const int smoothing ) { const size_t pos = get_global_id(0); const size_t d = get_global_id(1); const size_t total = get_global_size(0); const size_t dimension = get_global_size(1);
在内核参数中,我们收到指向两个数据缓冲区(源数据和结果)的指针,以及一个指定平滑尺度数量的常量。在这种情况下,我们不定义特定的平滑尺度步长,在于它已被假定为 “1”。平均窗口则扩展 2 个元素。因为我们在目标元素之前和之后都同等地扩展它。
重点要注意,平滑尺度的数字不能为负。如果该值为零,则简单地将源数据原封不动地传递。
我们计划在由完全独立线程组成的二维任务空间中执行该内核,无需创建局部工作组。第一个维度对应于正在分析的源序列大小,而第二个维度表示向量中描述每个序列元素的特征数量。
在内核主体中,我们立即按任务空间的所有维度识别当前线程,并判定它们各自的大小。
使用获得的数据,我们计算数据缓冲区内的偏移量。
const int shift_input = pos * dimension + d; const int shift_output = dimension * pos * smoothing + d;
此刻,准备阶段已经完成,我们直接生成多尺度表征。第一步是复制源数据,其对应于零级平均的表征。
float value = feature[shift_input]; if(isinf(value) || isnan(value)) value = 0; outputs[shift_output] = value;
接下来,我们组织一个循环来计算平均窗口内各个特征的平均值。正如您能想象的,这需要对窗口内的所有值求和,然后将累积总和除以汇总里包含的元素数量。
重点要注意,不同尺度的所有平均窗口都以正在分析的同一元素为中心环绕。由此,每个后续尺度都包含前一尺度中的所有元素。我们利用该属性的优点来最低限度地访问昂贵的全局内存:在每次迭代中,我们只将包含的新值加入先前累积的总和当中,然后将当前累积总和除以当前平均窗口中的元素数量。
for(int s = 1; s <= smoothing; s++) { if((pos - s) >= 0) { float temp = feature[shift_input - s * dimension]; if(isnan(temp) || isinf(temp)) temp = 0; value += temp; } if((pos + s) < total) { float temp = feature[shift_input + s * dimension]; if(isnan(temp) || isinf(temp)) temp = 0; value += temp; } float factor = 1.0f / (min((int)total, (int)(pos + s)) - max((int)(pos - s), 0) + 1); if(isinf(value) || isnan(value)) value = 0; float out = value * factor; if(isinf(out) || isnan(out)) out = 0; outputs[shift_output + s * dimension] = out; } }
还值得一提的是(尽管这听起来或许有点违反直觉),即同一尺度内的所有平均窗口并非都大小相同。这是由于序列中的边缘元素,其中平均窗口超出了序列两侧的边界。因此,在每次迭代中,我们都会计算平均中涉及的元素的实际数量。
以类似的举措,我们经由 FeatureSmoothingGradient 内核中的上述操作,构造了误差梯度传播算法,我建议您独立审查。完整的 OpenCL 程序代码可在附件中找到。
2.2构建 NAFS 类
在对 OpenCL 程序进行必要的添加后,我们转到主应用程序,其中我们将创建一个生成自适应节点嵌入格式的新类:CNeuronNAFS。新类结构如下所示。
class CNeuronNAFS : public CNeuronBaseOCL { protected: uint iDimension; uint iSmoothing; uint iUnits; //--- CNeuronBaseOCL cFeatureSmoothing; CNeuronTransposeOCL cTranspose; CNeuronBaseOCL cDistance; CNeuronSoftMaxOCL cAdaptation; //--- virtual bool FeatureSmoothing(const CNeuronBaseOCL *neuron, const CNeuronBaseOCL *smoothing); virtual bool FeatureSmoothingGradient(const CNeuronBaseOCL *neuron, const CNeuronBaseOCL *smoothing); //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL) override; virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL) override; virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) override { return true; } public: CNeuronNAFS(void) {}; ~CNeuronNAFS(void) {}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint step, uint units_count, ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual int Type(void) override const { return defNeuronNAFS; } //--- 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; };
如是所见,新类的结构声明了三个变量、和四个内层。我们将在实现算法来覆盖虚拟方法期间审阅它们的功能。
对于早前所述的 OpenCL 程序中的同名内核,我们还有两种包装方法。它们是采用标准内核调用算法构建的。您可在附件中自行找到代码。
新类的所有内部对象都声明为静态,允许我们将类构造函数和析构函数“留空”。这些声明和继承对象的初始化均在 Init 方法中执行。
bool CNeuronNAFS::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint dimension, uint smoothing, uint units_count, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, dimension * units_count, optimization_type, batch)) return false;
在方法参数中,我们接收主要常量,令我们能够唯一地判定正在创建的对象架构。这些包括:
- dimension – 描述单个序列元素的特征向量的大小;
- smoothing – 平滑尺度的数量(如果设置为零,则直接复制源数据);
- units_count – 正在分析的序列大小。
注意,所有参数都是无符号整数型。这种方式剔除了所接收参数为负数值的可能性。
在方法内部,如常,我们首先调用父类的同名方法,其已可处理继承对象的参数验证和初始化。结果张量的大小假定与输入张量的大小相匹配,其计算是所分析序列中的元素数量、与单个元素特征向量大小的乘积。
父类方法执行成功后,我们将外部提供的参数保存到相应名称的内部变量之中。
iDimension = dimension; iSmoothing = smoothing; iUnits = units_count;
接下来,我们转到初始化所声明对象。首先,我们声明存储多尺度节点表征矩阵的内层。它的大小必须足以存储完整的矩阵。因此,它是原始数据大小的(iSmoothing + 1) 倍大。
if(!cFeatureSmoothing.Init(0, 0, OpenCL, (iSmoothing + 1) * iUnits * iDimension, optimization, iBatch)) return false; cFeatureSmoothing.SetActivationFunction(None);
在构造多尺度节点表征(在我们的例子中,这些表示不同尺度的烛条形态)之后,我们需要计算这些表征与所分析柱线特征向量之间的余弦相似度。为此,我们将输入张量乘以多尺度节点表征张量。然而,在执行该乘法之前,我们必须首先转置多尺度表征张量。
if(!cTranspose.Init(0, 1, OpenCL, (iSmoothing + 1)*iUnits, iDimension, optimization, iBatch)) return false; cTranspose.SetActivationFunction(None);
矩阵乘法运算已在我们的基本神经层类中实现,并继承自父类。为了保存该操作的结果,我们初始化内部对象 cDistance。
if(!cDistance.Init(0, 2, OpenCL, (iSmoothing + 1)*iUnits, optimization, iBatch)) return false; cDistance.SetActivationFunction(None);
我要提醒您,指向同一方向的两个向量相乘会产生正值,而相反方向会产生负值。显然,如果所分析柱线与整体趋势一致,则柱线的特征向量和平滑值之间的乘法结果将为正。相较之,如果柱线与总体趋势相反,则结果将为负数。在横盘市场条件下,平滑值向量将接近于零。由此,乘法结果也接近于零。为了归一化结果值,并计算每个尺度的自适应影响系数,我们调用 Softmax 函数。
if(!cAdaptation.Init(0, 3, OpenCL, cDistance.Neurons(), optimization, iBatch)) return false; cAdaptation.SetActivationFunction(None); cAdaptation.SetHeads(iUnits);
现在,为了计算所分析节点(柱线)的最终嵌入,我们将每个节点的自适应系数向量乘以相应的多尺度表征矩阵。该操作的结果将写入接口的缓冲区,以便与从父类继承的后续层进行数据交换。因此,我们不会创建额外的内部对象。取而代之,我们简单地禁用激活函数,并完成初始化方法,将操作的逻辑结果返回给调用程序。
SetActivationFunction(None); //--- return true; }
新对象的初始化工作完成后,我们转至 feedForward 方法中构造前馈通验算法。在方法参数中,我们接收指向源数据对象的指针。
bool CNeuronNAFS::feedForward(CNeuronBaseOCL *NeuronOCL) { if(!FeatureSmoothing(NeuronOCL, cFeatureSmoothing.AsObject())) return false;
据该数据,我们首先调用前述的 FeatureSmoothing 内核包装器方法,来构造多尺度表征张量。
if(!FeatureSmoothing(NeuronOCL, cFeatureSmoothing.AsObject())) return false;
正如初始化算法描述中所解释的,我们随后转置生成的多尺度节点表征矩阵。
if(!cTranspose.FeedForward(cFeatureSmoothing.AsObject())) return false;
接下来,我们将其乘以输入张量,以便获得余弦相似系数。
if(!MatMul(NeuronOCL.getOutput(), cTranspose.getOutput(), cDistance.getOutput(), 1, iDimension, iSmoothing + 1, iUnits)) return false;
然后调用 Softmax 函数归一化这些系数。
if(!cAdaptation.FeedForward(cDistance.AsObject())) return false;
最后,我们将得到的自适应系数张量乘以先前形成的多尺度表征矩阵。
if(!MatMul(cAdaptation.getOutput(), cFeatureSmoothing.getOutput(), Output, 1, iSmoothing + 1, iDimension, iUnits)) return false; //--- return true; }
正如该操作的结果,我们获得了最终的节点嵌入,其会被存储在模型内部的神经层接口缓冲区之中。该方法将操作的逻辑结果返回给调用程序来完结。
下一阶段的开发涉及为我们的新 NAFS 框架类实现反向传播算法。这有两个关键特征需要考虑。首先,正如理论部分所述,我们的新对象不包含可训练参数。相应地,我们重写 updateInputWeights 方法,始终返回正结果作为存根。
virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) override { return true; }
不过,calcInputGradients 方法值得特别注意。尽管前馈通验很简单,但输入数据和多尺度表征矩阵都使用了两次。因此,为了将误差梯度传播回输入数据级别,我们必须小心地经由所构造算法的所有信息路径传递它。
bool CNeuronNAFS::calcInputGradients(CNeuronBaseOCL *NeuronOCL) { if(!NeuronOCL) return false;
该方法接收指向上一层对象的指针作为参数,其中接收所传播误差梯度。这些梯度必须按每个数据元素对模型最终输出的影响,呈比例分派。在方法主体中,我们首先检查所接收指针的有效性,因为继续使用无效引用会令所有后续操作变得毫无意义。
首先,我们需要把从后续层接收到的误差梯度分派到自适应系数和多尺度表征矩阵之间。然而,我们还计划经由自适应系数的信息路径将梯度传播回多尺度表征矩阵。故此,在该阶段,我们将多尺度表征张量的梯度存储在临时缓冲区之中。
if(!MatMulGrad(cAdaptation.getOutput(), cAdaptation.getGradient(), cFeatureSmoothing.getOutput(), cFeatureSmoothing.getPrevOutput(), Gradient, 1, iSmoothing + 1, iDimension, iUnits)) return false;
接下来,我们处理自适应系数的信息流。在此,我们通过调用相应对象的梯度分派方法,将误差梯度传播回余弦相似度张量。
if(!cDistance.calcHiddenGradients(cAdaptation.AsObject())) return false;
在随后步骤中,我们在输入数据和转置的多尺度表示张量之间分派误差梯度。再次,我们预计梯度会经由第二条信息路径进一步传播到输入数据级别。因此,我们在该阶段将相应的梯度保存在临时缓冲区之中。
if(!MatMulGrad(NeuronOCL.getOutput(), PrevOutput, cTranspose.getOutput(), cTranspose.getGradient(), cDistance.getGradient(), 1, iDimension, iSmoothing + 1, iUnits)) return false;
然后,我们转置多尺度表征的梯度张量,并将其与先前存储的数据相加。
if(!cFeatureSmoothing.calcHiddenGradients(cTranspose.AsObject()) || !SumAndNormilize(cFeatureSmoothing.getGradient(), cFeatureSmoothing.getPrevOutput(), cFeatureSmoothing.getGradient(), iDimension, false, 0, 0, 0, 1) ) return false;
最后,我们将累积误差梯度传播到输入数据级别。我们首先传递多尺度表示矩阵的误差梯度。
if(!FeatureSmoothingGradient(NeuronOCL, cFeatureSmoothing.AsObject()) || !SumAndNormilize(NeuronOCL.getGradient(), cFeatureSmoothing.getPrevOutput(), NeuronOCL.getGradient(), iDimension, false, 0, 0, 0, 1) || !DeActivation(NeuronOCL.getOutput(), NeuronOCL.getGradient(), NeuronOCL.getGradient(), (ENUM_ACTIVATION)NeuronOCL.Activation()) ) return false; //--- return true; }
然后我们加上之前保存的数据,并应用激活函数的导数来调整输入层梯度。该方法将操作的逻辑结果返回给调用程序来完结。
CNeuronNAFS 类方法的讲述到此完结。附件中提供了该类、及其所有方法的完整源代码。
2.3模型架构
关于可训练模型的架构有几句话要说。我们已将新的自适应特征平滑对象集成到环境状态编码器模型当中。该模型本身继承自上一篇专门介绍 AMCT 框架的文章。因此,新模型用到了这两个框架的方式。模型架构在 CreateEncoderDescriptions 方法中实现。
忠实于我们的一般模型设计原则,我们先创建一个全连接层,将源数据输入到模型之中。
bool CreateEncoderDescriptions(CArrayObj *&encoder) { //--- CLayerDescription *descr; //--- if(!encoder) { encoder = new CArrayObj(); if(!encoder) 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; }
应当注意的是,NAFS 算法允许将自适应平滑直接应用于原始输入数据。不过,我们必须记住,我们的模型直接从交易终端接收未经处理的原生数据。如是结果,所分析的特征可能具有非常不同的数值分布。为了把该因素的负面影响最小化,我们总是用到一个归一化层。我们在此应用相同的方式。
//--- 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; }
归一化随后,我们应用自适应特征平滑层。建议您自行实验该特定顺序,因为在计算平滑尺度的自适应注意力系数时,单个特征分布的显著差异或许会引发具有较高振幅值的某些特征占据主导地位。
新对象的大多数参数都适合已经熟悉的神经层描述结构。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronNAFS; descr.count = HistoryBars; descr.window = BarDescr; descr.batch = 1e4; descr.activation = None; descr.optimization = ADAM;
在这种情况下,我们使用 5 个平均尺度,对应于窗口 {1, 3, 5, 7, 9, 11} 的形式。
descr.window_out = 5; if(!encoder.Add(descr)) { delete descr; return false; }
编码器的其余架构保持不变,包括 AMCT 层。
//--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronAMCT; descr.window = BarDescr; // Window (Indicators to bar) { int temp[] = {HistoryBars, 50}; // Bars, Properties if(ArrayCopy(descr.units, temp) < (int)temp.Size()) return false; } descr.window_out = EmbeddingSize / 2; // Key Dimension descr.layers = 5; // Layers descr.step = 4; // Heads descr.batch = 1e4; descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; }
后随一个全连接降维层。
//--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; } //--- return true; }
参与者 和 评论者 模型的架构也维持不变。与它们一起,我们从之前的工作中转移了与环境交互和训练模型的程序。您可在附件中找到其完整代码。附件还包含准备文章时用到的所有程序的完整代码。
3. 测试
在前面的章节中,我们执行了广泛的工作,利用 MQL5 来实现 NAFS 框架作者提议的方法。现在是时候评估它们对我们特定任务的有效性了。为此,我们将据 EURUSD 的 2023 全年真实数据上训练模型的这些方法。至于交易,我们采用来自 H1 时间帧的历史数据。
如前,我们应用离线模型训练,定期更新训练数据集,以便将其相关性维持在由 参与者 当前政策生成的数值范围内。
我们之前提到过,新的环境状态编码器模型是建立在对比形态变换器上层的。为了清晰地比较结果,我们在新模型上进行的测试,完全保留了基线模型的测试参数。2024 年前三个月的测试结果如下。
初看,比较当前模型和基线模型之间的测试结果会产生不同的印象。一方面,我们观察到盈利系数从 1.4 下降到 1.29。另一方面,好在交易数量提升了 2.5 倍,同一测试区间的总盈利成比例增长。
此外,与基线模型不同,新模型在整个测试期间展现出一致的余额上升趋势。然而,只执行了做空。这或许是由于在平滑值当中更强烈专注全局趋势。如是结果,在噪声滤波期间一些局部趋势或许会被忽略。
然而,在分析该模型的月度绩效曲线时,我们观察到盈利能力随着时间的推移逐渐下降。这个观察支持了我们在上一篇文章中提出的假设:训练数据集的代表性随着测试期的延长而减弱。
结束语
在本文中,我们探讨了 NAFS(节点-自适应特征平滑)方法,这是一种简单而有效的非参数方式,无需参数训练即可在图形中构造节点表政。它结合了平滑的领域特征,并通过使用不同平滑策略的融汇,生成稳健、且信息丰富的最终嵌入。
在实践方面,我们针对所提以方法实现了 MQL5 版本的解释,据真实历史数据上训练构造的模型,并在样本之外的数据集上对其进行了测试。基于我们的实验,我们可以得出结论,所提议方式显示出潜力。它们能与其它框架结合使用。还有,它们的集成可以提升基线模型的效率。
参考
文章中所用程序
# | 名称 | 类型 | 说明 |
---|---|---|---|
1 | Research.mq5 | 智能系统 | 收集样本的 EA |
2 | ResearchRealORL.mq5 | 智能系统 | 利用 Real ORL方法收集样本的 EA |
3 | Study.mq5 | 智能系统 | 模型训练 EA |
4 | Test.mq5 | 智能系统 | 模型测试 EA |
5 | Trajectory.mqh | 类库 | 系统状态描述结构 |
6 | NeuroNet.mqh | 类库 | 创建神经网络的类库 |
7 | NeuroNet.cl | 函数库 | OpenCL 程序代码库 |
本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/16243


