神经网络在交易中的应用:一种端到端多变量时间序列预测模型(结论)
引言
在之前的文章中,我们已从理论方面的介绍进展到所提方法的实际实现,逐步揭示了 GinAR 的架构和逻辑 — 这是一个专为在结构不确定性和高噪声水平条件下分析和预测时间序列而开发的原创神经网络框架。基于多层、多组件架构,GinAR 成功融合了注意力机制、图变换和循环动态,从而不仅具备感知数据结构的能力,还能实时适应数据结构。
现在,我们已经进入了最关键 — 或许也是最有趣 — 的部分:在真实条件下评估 GinAR 框架。目前,我们已经完成了架构设计、关键组件的实现以及前向和后向传播算法的开发阶段。系统的所有组件都已得到充分开发、测试,并适应了金融时间序列的特定特征。现在是时候将它们整合在一起,正如人们所说,让它们大展身手 — 看看 GinAR 在处理实际分析和预测任务时有多高效。
在本文中,我们不仅仅是完成了开发工作;我们更是标志着一种概念的最终实现,这一概念的落实需要付出相当大的努力。主要重点是在接近真实世界部署的条件下训练和测试模型。
在进行评估之前,让我们先简要回顾一下 GinAR 框架本身的结构。其核心是一个自定义单元,GinARCell,它结合了几个关键组件:
- 插值注意力(IA)是一种上下文注意力机制,它对输入流进行预处理,并使模型聚焦于相关元素。它是一种初始感知过滤器,能够在主要转换之前识别重要模式并重建缺失元素。
- AGCN 模块(自适应图卷积网络)由三个模块组成,其中一个模块负责主要处理( cX_AGNC ),而另外两个模块处理控制信号( ForgetGate 和 ResetGate )。他们的任务是提取结构和拓扑信息,并根据时间和结构依赖关系对数据流进行建模。
- 上下文模块(Context)是一个时序状态存储,可在处理流程中提供深度记忆和连续性。其状态根据与控制门(gates)的交互,按照加权方案进行更新。
- 双重控制机制 — ForgetGate 和 ResetGate — 的工作原理是过滤和覆盖上下文。它们被实现为独立的 AGCN 单元,从而可以灵活地控制模型的内部状态。
单元的输出是基于 ELU 激活和上下文注意力乘数形成的,这确保了时间间隔之间平滑而灵敏的过渡。
正是这种组合造就了 GinAR 的独特之处。与经典的循环神经网络不同,它不仅能记住过去,还能对过去进行结构化处理。与标准 GCN 模型不同,它不是在固定的拓扑结构上运行的;相反,它是动态学习拓扑结构的。最后,与广义 Transformer 不同, GinAR 保留了局部性和动态适应性 — 这两个特性对于分析金融时间序列至关重要。
作者提供的 GinAR 框架的示意图如下所示。

本文不仅总结了技术部分,还展示了整个设计的成熟度。我们将看到框架在实际中的表现如何,会出现哪些瓶颈,以及模型在新数据上能达到何种准确度和鲁棒性。最重要的是,我们将评估它是否真的能够为交易者提供足够的优势,以证明所有这些架构复杂性的合理性。
GinAR 模块
GinAR 架构采用分层结构。它由几个连续的 GinAR 层组成,并以基于多层感知器 ( MLP ) 的解码器(Decoder)结束。每个 GinAR 层又包含一组 GinARCell 单元,这些单元逐步处理时间序列,就像电影的帧构成连续的场景一样。
在每一层的输出端,只保留最后一个单元的最终隐藏状态。这些状态被组合成一个最终张量 hⁿₐₗₗ ,其中包含对整个分析序列的压缩和最大信息量的表示。这个张量被输入到 MLP 解码器中,该解码器由两个全连接层组成,中间有一个 ReLU 激活函数。
值得注意的是, GinAR 多层结构中的数据流组织是一个重要的架构方面。就其本身而言,依次应用多个层(其中每个后续层接收前一个层的输出作为其输入)是一种完全标准的做法,并且非常符合线性模型的逻辑。
然而,原始的 GinAR 模型采用了一种更为复杂的方案:在经过多层的顺序处理后,结果并非仅仅是最后一层的输出,而是通过将所有层的输出进行拼接而得到的综合表示。这种方法使解码器能够利用在不同抽象级别上生成的所有内部特征,这无疑增强了模型的性能。然而,它也会破坏我们实现所依赖的线性数据传输结构,并需要额外的逻辑来收集和合并来自不同层的数据。
我们使用的高级模型构建对象最初并不支持将多个层的输出合并为一个张量的操作,这使得无法直接实现原始 GinAR 架构的这一部分。为了解决这一限制,我们开发了一个特殊的 GinAR 模块,该块使用一组 CNeuronGinARCell 单元来管理输入数据的顺序处理,然后将所有单元的结果聚合为一个张量。这个最终的张量被输入到解码器中,复制了原始模型的一个关键特性 — 在不同抽象层次上获得的特征的整合。
当我们开始研究 GinAR 模块的架构时,值得强调它与原始模型的一个重要区别。除了基本结构之外,我们还添加了一个可学习的矩阵,该矩阵定义了被分析数据的各个组成部分之间的关系。它基于一种自适应学习机制,类似于每个 GinARCell 单元内的结构依赖矩阵中使用的机制。然而,关键区别在于适用范围。虽然每个单元中分别形成自适应矩阵,反映局部模式,但我们引入的矩阵本质上是全局的,并且由单个 GinAR 模块内的所有单元共享。这使得在整个分析过程中能够捕捉到变量之间稳定且反复出现的相关性,从而增强模型的结构一致性。
为了在我们的架构中实现 GinAR 模块,我们创建了一个名为 CNeuronGinAR 的 特殊类,它继承自 CNeuronSwiGLUOCL 。这种层级结构使我们能够与模型的其余部分保持兼容性,并利用底层激活设计的优势以及灵活的 SwiGLU 非线性。然而,关键功能要素和操作逻辑得到了显著扩展和重新定义。新对象的结构如下所示。
class CNeuronGinAR : public CNeuronSwiGLUOCL { protected: CParams cEa; CNeuronSwiGLUOCL cWx; CNeuronSwiGLUOCL cWe; CNeuronBaseOCL cWconcat_ex; CNeuronConvOCL cEn; CNeuronTransposeOCL cEnT; CNeuronBaseOCL cEnEnT; CNeuronSoftMaxOCL cApre; CNeuronGinARCell caCells[4]; CNeuronBaseOCL cConcat; //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL) override; virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) override; virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL) override; public: CNeuronGinAR(void) {}; ~CNeuronGinAR(void) {}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint units_count, uint dimension, ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual bool Save(const int file_handle) override; virtual bool Load(const int file_handle) override; //--- virtual int Type(void) override const { return defNeuronGinAR; } virtual void TrainMode(bool flag) override; virtual void SetOpenCL(COpenCLMy *obj); //--- virtual bool WeightsUpdate(CNeuronBaseOCL *source, float tau); //--- virtual bool Clear(void) override; };
CNeuronGinAR 类是 GinAR 框架的一个完整模块,在 MQL5 环境中使用 OpenCL 实现。它旨在处理时间序列并根据线性关系层次提取复杂模式。该类将负责生成和解释结构依赖性全局矩阵的几个关键组件,以及对输入数据进行顺序处理的 CNeuronGinARCell 单元数组结合在一起。随后将它们的结果连接起来,形成被分析序列的最终表示。
类结构基于模块化和可重用性的原则。所有内部元素均采用静态声明。这意味着它们的生命周期管理(创建、销毁和内存清理)是自动处理的,无需构造函数或析构函数的显式干预。
Init 方法用于配置所有内部组件;它作为初始化过程的唯一入口点。它接受定义对象架构和参数优化类型的参数。
bool CNeuronGinAR::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint units_count, uint dimension, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronSwiGLUOCL::Init(numOutputs, myIndex, open_cl, caCells.Size()*dimension, caCells.Size()*dimension, dimension, units_count, 1, optimization_type, batch)) return false;
首先,调用父类 CNeuronSwiGLUOCL 中同名的方法;在我们的实现中,它形成 GinARCell 单元产生的结果的聚合表示。之后,作为块架构一部分的新声明组件将按顺序进行初始化。
第一个是时间序列变量之间全局结构依赖关系的可学习矩阵。
int index = 0; if(!cEa.Init(0, index, OpenCL, Neurons(), optimization, iBatch)) return false; cEa.SetActivationFunction(None);
与原始方法不同,在原始方法中,基于先验知识或对预测序列结构的初步分析来构建预定义的协方差矩阵,而我们的实现则使用一个可学习的结构依赖矩阵,该矩阵在单个块内的所有 GinARCell 单元中是通用的。这种方法消除了手动调整或外部数据分析的需求,使得在训练阶段能够更灵活、自适应地调整变量之间的关系。
接下来,我们初始化线性变换层 cWx 和 cWe 。它们的目的是在将输入数据和协方差矩阵合并为一个张量 cWconcat_ex 之前,将它们映射到一个共同的维度。
index++; if(!cWx.Init(0, index, OpenCL, dimension, dimension, dimension, units_count, 1, optimization, iBatch)) return false; index++; if(!cWe.Init(0, index, OpenCL, dimension, dimension, dimension, units_count, 1, optimization, iBatch)) return false; if(!cWconcat_ex.Init(0, index, OpenCL, 2 * Neurons(), optimization, iBatch)) return false; cWconcat_ex.SetActivationFunction(None);
cEn 卷积层旨在对特征及其依赖关系进行深度混合。
index++; if(!cEn.Init(0, index, OpenCL, 2 * dimension, 2 * dimension, dimension, units_count, 1, optimization, iBatch)) return false; cEn.SetActivationFunction(None); index++; if(!cEnT.Init(0, index, OpenCL, units_count, dimension, optimization, iBatch)) return false; index++; if(!cEnEnT.Init(0, index, OpenCL, units_count * units_count, optimization, iBatch)) return false; cEnEnT.SetActivationFunction(GELU);
我们首先转置所得数据,然后将原始表示与其转置相乘,得到变量间相互依赖关系的对称矩阵 cEnEnT 。GELU 函数用于激活,增强显著关系。
接下来,我们使用 SoftMax 函数对依赖矩阵进行归一化,将数据转换为概率分布。
index++; if(!cApre.Init(0, index, OpenCL, units_count * units_count, optimization, iBatch)) return false; cApre.SetHeads(units_count);
下一步是初始化一个由 CNeuronGinARCell 个单元组成的数组,每个单元负责在其层次结构中的相应层级处理正在分析的序列。尽管这些单元在逻辑上是独立的,但它们具有相同的架构,这简化了初始化过程 — 一个结构良好的循环就足够了。
for(uint i = 0; i < caCells.Size(); i++) { index++; if(!caCells[i].Init(0, index, OpenCL, units_count, dimension, optimization, iBatch)) return false; } index++; if(!cConcat.Init(0, index, OpenCL, GetWindow()*units_count, optimization, iBatch)) return false; //--- return true; }
这些单元的结果在 cConcat 对象中进行聚合。在成功初始化所有内部组件后,我们将操作的布尔结果返回给调用程序。
需要注意的是,Init 方法并不管理所列对象的生命周期。它的任务是精确配置参数并设置层架构,然后将控制权交给模型的执行周期。这种方法确保了高稳定性、可预测的行为和高效的内存使用。
初始化阶段完成后, CNeuronGinAR 模块的所有组件都已准备好在前向传播期间运行。这就是结构模块、隐藏单元状态和可学习依赖矩阵之间的相互作用变得明显的地方。feedForward 方法负责执行输入序列的完整处理周期,包括构建结构依赖矩阵和生成最终输出张量,该张量将被传递给神经架构的下一层。
bool CNeuronGinAR::feedForward(CNeuronBaseOCL *NeuronOCL) { //--- Calculate Apre if(bTrain) { if(!cEa.FeedForward()) return false; if(!cWe.FeedForward(cEa.AsObject())) return false; }
第一步是构建可学习的依赖矩阵。如果模型处于训练模式(bTrain == true),则首先激活全局结构依赖张量,然后通过线性投影层处理其输出。
同时,输入数据的主要信息流在通过 cWx 线性投影层时也会被处理。
//--- if(!cWx.FeedForward(NeuronOCL)) return false; if(!Concat(cWx.getOutput(), cWe.getOutput(), cWconcat_ex.getOutput(), cWx.GetWindowOut(), cWe.GetWindowOut(), cWx.GetUnits())) return false;
接下来,使用 Concat 操作将两个分支的结果合并成一个张量,并将结果输入到 cEn 卷积层中。
if(!cEn.FeedForward(cWconcat_ex.AsObject())) return false; if(!cEnT.FeedForward(cEn.AsObject())) return false; if(!MatMul(cEn.getOutput(), cEnT.getOutput(), cEnEnT.getOutput(), cEnT.GetCount(), cEnT.GetWindow(), cEnT.GetCount())) return false; if(cEnEnT.Activation() != None) if(!Activation(cEnEnT.getOutput(), cEnEnT.getOutput(), cEnEnT.Activation())) return false;
然后将得到的张量进行转置(cEnT),之后将原始张量与其转置后的版本相乘。在此阶段,实际上形成了一个协方差矩阵,并且,如有必要,会使用指定的激活函数来添加非线性。
最终的协方差矩阵是在 cApre 层中构建的,在该层中,数据通过 SoftMax 函数转换为概率表示形式。
if(!cApre.FeedForward(cEnEnT.AsObject())) return false; if(!IdentSum(cApre.getOutput(), cApre.getOutput(), cApre.Heads())) return false;
为确保稳定性和自连接的基线水平,该矩阵补充了一个单位矩阵。换句话说,每个变量不仅接收关于其与其他变量关系的信息,还保留基本的自我参照。其结果是形成一个平衡的注意力权重结构,既能考虑全局依赖性,又能考虑每个元素的局部重要性。正是这个最终的矩阵被传递给每个 GinARCell 单元的输入端,作为选择性信号放大的通用机制。
接下来,输入序列的主要处理过程通过单元阵列开始。从外部程序接收到的待分析数据被输入到第一个单元的输入端。每个后续单元都会分析前一个单元的结果,从而增加潜在表示的细节层次。因此,形成了一系列的隐藏表示,反映了时间序列分析的层次性。
//--- GimAR Cells CNeuronBaseOCL *temp = NeuronOCL; for(uint i = 0; i < caCells.Size(); i++) { if(!caCells[i].FeedForward(temp, cApre.getOutput())) return false; temp = caCells[i].AsObject(); } //--- if(!Concat(caCells[0].getOutput(), caCells[1].getOutput(), caCells[2].getOutput(), caCells[3].getOutput(), cConcat.getOutput(), GetWindow() / 4, GetWindow() / 4, GetWindow() / 4, GetWindow() / 4, GetUnits())) return false; //--- return CNeuronSwiGLUOCL::feedForward(cConcat.AsObject()); }
该方法的最后一步是将所有细胞的输出合并成一个公共张量( cConcat ),然后将其作为输入传递给父类 CNeuronSwiGLUOCL 中同名的方法进行进一步处理。这个结构确保了 GinAR 模块中信息的协调和高效流动,尽管所使用的框架存在架构上的局限性,但仍然使系统的行为尽可能接近原始架构。
在完成前向传播并计算出块的结果后,我们进入第二个关键阶段:误差梯度传播(反向传播)。这是预测真正逆向工程的开始:将解码器的误差反馈到架构的每个元素,以便精确调整权重并优化模型。
calcInputGradients 方法负责对整个模块架构执行完整的反向传播,从输出开始,到输入数据结束。由于该模块的结构复杂,既包含顺序操作,也包含张量分支和合并操作,因此反向传播需要逐步且严格协调地执行。
bool CNeuronGinAR::calcInputGradients(CNeuronBaseOCL *NeuronOCL) { if(!NeuronOCL) return false; //--- if(!CNeuronSwiGLUOCL::calcInputGradients(cConcat.AsObject())) return false; if(!DeConcat(caCells[0].getPrevOutput(), caCells[1].getPrevOutput(), caCells[2].getPrevOutput(), caCells[3].getGradient(), cConcat.getGradient(), GetWindow() / 4, GetWindow() / 4, GetWindow() / 4, GetWindow() / 4, GetUnits())) return false;
该方法首先调用父类 CNeuronSwiGLUOCL 中同名的方法,该方法将梯度传递给内部单元输出的连接张量: cConcat 。由于在前向传播过程中,该张量是由四个 GinAR 单元的输出连接而成的,因此现在需要正确地将梯度重新分配到四个信息流中。使用 DeConcat 方法,我们根据误差梯度对最终结果的影响,将误差梯度传递给每个单元。
关于 GinAR 模块中误差梯度传播的逻辑,有一点需要特别强调。尽管这些单元表面上是对称的,但它们在学习过程中的功能作用并不相同。
首先,仅使用最后一个单元通过连接来形成块的最终结果张量。前面的所有单元都具有双重用途:它们的输出不仅参与连接,而且还作为层次结构中下一个 GinARCell 的 输入数据。这意味着误差梯度必须从两个来源进入每个这样的单元。因此,在计算前三个单元的梯度时,我们收集数据所使用的两条路径上的梯度,并正确地将它们相加。这确保了误差流的连续性,并在层级结构的每个层级都能获得准确的反馈。
//--- GimAR Cells cApre.getGradient().Fill(0); for(uint i = caCells.Size() - 1; i > 0; i--) if(!caCells[i - 1].CalcHiddenGradients(caCells[i].AsObject(), cApre.getOutput(), cApre.getPrevOutput(), (ENUM_ACTIVATION)cApre.Activation()) || !SumAndNormilize(caCells[i - 1].getGradient(), caCells[i - 1].getPrevOutput(), caCells[i - 1].getGradient(), cWx.GetWindow(), false, 0, 0, 0, 1) || !SumAndNormilize(cApre.getGradient(), cApre.getPrevOutput(), cApre.getGradient(), GetUnits(), false, 0, 0, 0, 1)) return false; if(!NeuronOCL.CalcHiddenGradients(caCells[0].AsObject(), cApre.getOutput(), cApre.getPrevOutput(), (ENUM_ACTIVATION)cApre.Activation()) || !SumAndNormilize(cApre.getGradient(), cApre.getPrevOutput(), cApre.getGradient(), GetUnits(), false, 0, 0, 0, 1)) return false;
其次,当计算由 cApre 对象表示的全局协方差矩阵的梯度时,会出现更加微妙的情况。该矩阵同时传递给所有四个 GinARCell 实例。因此,在反向传播过程中,其梯度必须从四个独立方向(每个单元)积累信息。这需要将所有传入的梯度逐一添加,同时保持一致的形状和比例。这部分操作中的误差会导致权重出现剧烈波动,从而导致训练不稳定。
接下来,反向传播通过依赖矩阵形成模块开始。首先计算 cEnEnT 的梯度,然后通过相应激活函数的导数调整值。
//--- if(!cEnEnT.CalcHiddenGradients(cApre.AsObject())) return false; if(cEnEnT.Activation() != None) if(!DeActivation(cEnEnT.getOutput(), cEnEnT.getGradient(), cEnEnT.getGradient(), cEnEnT.Activation())) return false; if(!MatMulGrad(cEn.getOutput(), cEn.getPrevOutput(), cEnT.getOutput(), cEnT.getGradient(), cEnEnT.getGradient(), cEnT.GetCount(), cEnT.GetWindow(), cEnT.GetCount())) return false; if(!cEn.CalcHiddenGradients(cEnT.AsObject())) return false; if(!SumAndNormilize(cEn.getGradient(), cEn.getPrevOutput(), cEn.getGradient(), cEnT.GetWindow(), false, 0, 0, 0, 1)) return false; if(cEn.Activation() != None) if(!DeActivation(cEn.getOutput(), cEn.getGradient(), cEn.getGradient(), cEn.Activation())) return false;
接下来,计算 MatMulGrad 矩阵乘法运算的梯度,从而将误差分配到卷积层 cEn 及其转置副本 cEnT 之间。同时,我们还将转置表示的误差梯度向下传播到卷积层,并将从两个信息流中获得的值相加。这种方法可以构建对称协方差矩阵的完全可微表示,从而即使在复杂的拓扑结构中也能保持误差传播的准确性和完整性。
我们将获得的值向下传播到 cWconcat_ex 张量的级别。
if(!cWconcat_ex.CalcHiddenGradients(cEn.AsObject())) return false; if(!DeConcat(cWx.getGradient(), cWe.getGradient(), cWconcat_ex.getGradient(), cWx.GetWindowOut(), cWe.GetWindowOut(), cWx.GetUnits())) return false;
cWconcat_ex 输出是由 cWx 和 cWe 的结果形成的。现在梯度随后通过相应的反向激活计算进行传播。之后,从 cWx 向源数据对象 NeuronOCL 发起反向传播。
但是,我们应该记住,来自第一个单元的误差梯度已经传递到源数据级别。因此,我们将使用一种技巧,即替换指向数据缓冲区的指针,然后将从两个信息流中获得的值相加。
if(cWx.Activation() != None) if(!DeActivation(cWx.getOutput(), cWx.getGradient(), cWx.getGradient(), cWx.Activation())) return false; if(cWe.Activation() != None) if(!DeActivation(cWe.getOutput(), cWe.getGradient(), cWe.getGradient(), cWe.Activation())) return false; CBufferFloat* temp = NeuronOCL.getGradient(); if(!NeuronOCL.SetGradient(NeuronOCL.getPrevOutput(), false)) return false; if(!NeuronOCL.CalcHiddenGradients(cWx.AsObject())) return false; if(!SumAndNormilize(NeuronOCL.getGradient(), temp, NeuronOCL.getGradient(), cWx.GetWindow(), false, 0, 0, 0, 1)) return false; if(!NeuronOCL.SetGradient(temp, false)) return false;
该方法最后将误差梯度传递到全局依赖矩阵 cEa 的层面。
if(!cEa.CalcHiddenGradients(cWe.AsObject())) return false; //--- return true; }
所有迭代成功完成后,我们将该方法的布尔结果返回给调用程序。
因此, calcInputGradients 方法确保梯度在整个 GinAR 块的结构中正确传播。反向传播的架构与正向传播的结构完全一致,这在使用复杂的注意力机制和嵌套组件时尤为重要。
附件中提供了该类及其所有方法的完整代码,可供独立学习。
模型架构
现在我们已经创建了构建 GinAR 框架所需的所有组件,接下来我们将进入下一个阶段:描述可训练模型的架构。在这里,架构远远超越了简单的时间序列预测:我们的目标是构建一个功能齐全的交易代理,其中 GinAR 框架充当环境状态编码器。
模型中的每个组件都负责特定的功能。Encoder 负责从历史数据中提取和汇总信息。预测模块分为三个并行模型,分别对不同的时间范围进行预测。接下来是 Actor(策略网络) 和 Critic(价值评估网络) 模块,它们对应于强化学习架构:前者生成交易行为,后者评估模型在当前市场状态下的预期表现。
CreateDescriptions 方法用于描述架构;它会为所有模块生成配置。第一步,该方法为每个模型初始化容器:Encoder、预测模块、 Actor 和 Critic 。这些容器以对象数组的形式实现,即神经层的描述。
bool CreateDescriptions(CArrayObj *&encoder, CArrayObj *&forecast1, CArrayObj *&forecast2, CArrayObj *&forecast3, CArrayObj *&actor, CArrayObj *&critic ) { //--- CLayerDescription *descr; //--- if(!encoder) { encoder = new CArrayObj(); if(!encoder) return false; } if(!forecast1) { forecast1 = new CArrayObj(); if(!forecast1) return false; } if(!forecast2) { forecast2 = new CArrayObj(); if(!forecast2) return false; } if(!forecast3) { forecast3 = new CArrayObj(); if(!forecast3) return false; } if(!actor) { actor = new CArrayObj(); if(!actor) return false; } if(!critic) { critic = new CArrayObj(); if(!critic) return false; }
初始化完成后,模型结构开始成形。Encoder 以一个基本的全连接层开始,该层只是接收模型的历史输入数据。
//--- Encoder encoder.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; uint 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 = defNeuronBatchNormWithNoise; descr.count = prev_count; descr.batch = BatchSize; descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; }
接下来是一个带有附加噪声的批量归一化层,其目的是稳定原始数据的分布,并在系统中引入表示增强,以提高模型对过拟合的鲁棒性。
之后,添加相邻 K 线之间的差异特征通道。这使得系统不仅能够捕捉绝对值,还能捕捉变化的方向和强度。在市场变化频繁且不规则的情况下,这一点尤为重要。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConcatDiff; prev_count = descr.count = HistoryBars; descr.layers = BarDescr; descr.step = 1; descr.batch = BatchSize; descr.optimization = ADAM; descr.activation = None; if(!encoder.Add(descr)) { delete descr; return false; }
下一层负责使用时间戳谐波来丰富表示。
//--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defMamba4CastEmbeding; prev_count = descr.count = HistoryBars; descr.window = 2 * BarDescr; uint prev_out = descr.window_out = NSkills; { uint temp[] = {PeriodSeconds(PERIOD_H1), PeriodSeconds(PERIOD_D1)}; if(ArrayCopy(descr.windows, temp) < (int)temp.Size()) return false; } descr.batch = BatchSize; descr.optimization = ADAM; descr.activation = None; if(!encoder.Add(descr)) { delete descr; return false; }
预处理后的数据随后会通过一个转置层,该层会改变表示轴:时间序列被转换为空间特征,并被输入到卷积滤波器中。具有 tanh 激活函数的卷积层能够识别嵌入中的关键模式和结构依赖关系,从而生成隐藏市场规律的压缩、集中的表示。
//--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronTransposeOCL; descr.count = prev_count; prev_count = descr.window = prev_out; descr.batch = BatchSize; descr.optimization = ADAM; descr.activation = None; if(!encoder.Add(descr)) { delete descr; return false; } prev_out = descr.count; //--- layer 5 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; descr.count = prev_count; descr.window = prev_out; descr.variables = 1; prev_out = descr.window_out = EmbeddingSize; descr.batch = BatchSize; descr.optimization = ADAM; descr.activation = TANH; if(!encoder.Add(descr)) { delete descr; return false; }
Encoder 的最终层是实现 GinAR 框架的层。在这里,所有先前获得的特征都通过 GinAR 单元的层次结构进行组合和解释。它们同步运行,使用共享的可学习协方差矩阵来对数据中隐藏的结构依赖性进行建模。这一层不仅仅是传递信息 — 它形成了市场状态的动态表征,可以认为已经可以用于做出交易决策。
//--- layer 6 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronGinAR; descr.count = prev_count; descr.window = prev_out; descr.batch = BatchSize; descr.optimization = ADAM; descr.activation = None; if(!encoder.Add(descr)) { delete descr; return false; }
因此, GinAR 框架的方法以一致、合乎逻辑的方式集成到模型架构中,而不会产生不必要的复杂性。Encoder 形成的最终隐藏状态是对市场状况的高级描述,既适用于预测,也适用于生成交易决策。
为了训练环境状态编码器,采用了一种策略,即同时从多个独立的子系统进行端到端的误差梯度传播。首先,这是三个预测模型,每个模型都专门用于预测其自身规划范围内时间序列的延续性。这些模块分析 Encoder 生成的相同隐藏状态,但根据指定的规划范围,对其进行不同的解释。这有助于从多尺度上理解当前的市场状况。
此外,还添加了 Actor — 一个负责生成交易决策的组件。它依赖于环境状态的相同压缩表示,并将其解释为行动指南。因此,Encoder 同时受到来自四个方面的压力:其输出必须既对预测具有信息价值,又适合在市场不确定性下的实际应用。
需要强调的是,所有这些模型的架构完全借鉴了之前的工作。因此,本研究将不对其详细描述进行深入探讨。重点完全在于新部分 — 将 GinAR 框架集成到可训练系统中。
附件中提供了所有组件的完整代码,包括层配置。
训练
在完成了所有组件的构建和可训练模型架构的配置后,我们进入了最后、或许也是最关键的阶段 — 模型训练。在我们的系统中,我们采用了一种结合了离线训练和在线训练模式优势的综合方法。这不仅确保了模型的初始训练稳健,还使其行为能够适应特定的交易条件,包括市场阶段的变化、波动性水平和交易信号的频率。
与以往的研究一样,训练过程分为两个阶段。第一阶段是离线训练。在此阶段,模型基于历史数据进行训练。此外,我们采用了一种无需提前收集训练样本的方法。我们直接根据终端的历史数据构建环境状态,并同时模拟账户状态。这种方法提供了高度的灵活性,使得可以使用任意历史区间,而无需手动准备数据集。
第二阶段是在线微调。这一阶段在 MetaTrader 5 策略测试器中,尽可能接近真实交易的环境下进行。模型在遇到新的、以前未见过的市场场景时,会继续训练并实时适应。这一阶段可以解决离线训练过程中累积的残余不匹配问题,并为系统在真实账户上的完全自主运行做好准备。
值得注意的是,在我们的研究中,两阶段训练并非一个新概念。我们之前的工作中也已成功应用了这一策略。然而,在当前项目中,离线训练阶段发生了根本性的重要变化,这些变化既基于理论考量,也源于实施先前模型所积累的实践经验。
您可能已经注意到,当前的架构缺少一个名为 Director 的组件。回想一下,在最近的一些研究中,Director 被用作与 Critic 并行运行的额外评估模型。其主要功能是对 Agent 的行为进行二元分类,即判断其是有利可图的还是会造成损失的。这种刚性的反馈形式在训练的早期阶段被证明是有效的,有助于加速策略的初步适应。
然而,在当前实现中,我们采用了不同的方法。因此,我们决定在离线训练阶段直接结合两种训练方法 — 强化学习和监督学习。这一解决方案在反馈的质量和重点方面被证明既更加优雅又更加有效。
变化的本质如下:在由 Critic 对 Agent 当前行为进行评估的同时,我们为 Actor 提供基于未来价格走势分析得出的参考行为。由于在离线训练阶段,我们可以访问包括后续价格走势在内的完整时间序列段,因此我们可以计算出所谓的近乎完美的行为轨迹。这些事后生成的行动不仅仅是简单地指明方向,它们成为Actor策略努力追求的一种参考点。
因此,我们实现了一种机制,使 Agent 既能从自身的错误中学习,也能从预先计算的有利策略示例中学习。这使得 Agent 能够在不失去训练效果的情况下,摆脱来自 Director 的严苛二元反馈。相反,引导信号的质量得到了提高,制定有利策略的过程也变得更加可控和稳健。
所提出的方法在位于 “…\MQL5\Experts\GinAR\Study.mq5” 的 EA 的 Train 方法中实现。该方法的算法构成了离线训练的核心阶段,将强化学习与监督学习相结合。它基于使用历史数据对市场情况进行顺序模拟,并通过分析当前状态和交易决策的理想化轨迹来训练交易策略。
一切从准备训练数据集开始:从历史价格数据中选定用户指定的范围,并为每个时间间隔计算要分析的技术指标。在此步骤中,我们确保所有指标均已计算完毕并准备就绪;否则,将中断流程。
void Train(void) { int start = iBarShift(Symb.Name(), TimeFrame, Start); int end = iBarShift(Symb.Name(), TimeFrame, End); int bars = CopyRates(Symb.Name(), TimeFrame, 0, start, Rates); //--- if(!RSI.BufferResize(bars) || !CCI.BufferResize(bars) || !ATR.BufferResize(bars) || !MACD.BufferResize(bars)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); ExpertRemove(); return; } //--- int count = -1; bool calculated = false; do { count++; calculated = (RSI.BarsCalculated() >= bars && CCI.BarsCalculated() >= bars && ATR.BarsCalculated() >= bars && MACD.BarsCalculated() >= bars ); Sleep(100); count++; } while(!calculated && count < 100); if(!calculated) { PrintFormat("%s -> %d The training data has not been loaded", __FUNCTION__, __LINE__); ExpertRemove(); return; } RSI.Refresh(); CCI.Refresh(); ATR.Refresh(); MACD.Refresh(); //--- if(!ArraySetAsSeries(Rates, true)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); ExpertRemove(); return; } bars -= end + HistoryBars + NForecast; if(bars < 0) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); ExpertRemove(); return; }
创建缓冲区是为了存储输入状态、时间戳和目标值。
接下来,主训练循环开始。训练被组织为一系列的轮次 — 对所选历史数据的完整遍历。在每个轮次内,市场情况会被依次模拟。
//--- vector<float> result, target, neg_target; bool Stop = false; //--- uint ticks = GetTickCount(); //--- for(int epoch = 0; (epoch < Epochs && !IsStopped() && !Stop); epoch ++) { if(!cEncoder.Clear()) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); ExpertRemove(); return; } for(int posit = start - HistoryBars - NForecast - 1; posit >= end; posit--) { if(!CreateBuffers(posit, GetPointer(bState), GetPointer(bTime), Result)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); ExpertRemove(); return; } const vector<float> account = SampleAccount(GetPointer(bState), datetime(bTime[0])); const vector<float> target_action = OraculAction(account, Result); if(!bAccount.AssignArray(account)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); ExpertRemove(); return; }
在每一步中,当前市场状态被构建为一个数值向量,并确定相应的时间轴。这些数据被输入到 Encoder 中,它将状态转换为一种紧凑的隐藏表示 — 即市场图景的一种精髓。
//--- Feed Forward if(!cEncoder.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CBufferFloat*)GetPointer(bTime))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
生成的潜在向量同时以三种方式使用。一方面,它被输入到三个预测模型中,每个模型负责其各自的规划范围:短期、中期和长期。这些模型为未来时段提供预测。
for(uint f = 0; f < caForecast.Size(); f++) if(!caForecast[f].feedForward(GetPointer(cEncoder), -1, (CBufferFloat*)NULL)) { PrintFormat("%s -> %d - Forecast %d", __FUNCTION__, __LINE__, f); Stop = true; break; }
另一方面,相同的隐藏表示被传递给 Actor,由其负责做出交易决策。它根据市场状况和当前账户状况生成行动张量。
if(!cActor.feedForward(GetPointer(bAccount), 1, false, GetPointer(cEncoder), LatentLayer)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
所做的决策由 Critic 进行评估,该评估会判断在给定条件下该决策的合理性。
if(!cCritic.feedForward(GetPointer(cActor), -1, GetPointer(cEncoder), LatentLayer)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
在执行前向传播后,我们需要向模型提供反馈。首先,调整预测模型的参数。
//--- Study for(uint f = 0; f < caForecast.Size(); f++) if(!caForecast[f].backProp(Result, (CBufferFloat*)NULL) || !cEncoder.backPropGradient((CBufferFloat*)NULL)) { PrintFormat("%s -> %d - Forecast %d", __FUNCTION__, __LINE__, f); Stop = true; break; }
对 Actor 行动的评估基于计算奖励:即净值变化与参考余额的比值。若发生损失,则惩罚加倍,以使反馈更为严格且信息量更大。我们将得到的奖励传递给 Critic。然后,从 Critic 中,我们将误差梯度反向传播到 Actor 和 Encoder。
//--- cActor.getResults(Action); double equity = bAccount[2] * bAccount[0] * EtalonBalance / (1 + bAccount[1]); double reward = CheckAction(Action, equity, posit - NForecast + 1) / EtalonBalance; if(reward < 0) reward *= 2; Result.Clear(); if(!Result.Add(float(reward))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; } if(!cCritic.backProp(Result, GetPointer(cEncoder), LatentLayer) || !cActor.backPropGradient(GetPointer(cEncoder), LatentLayer, -1, false) || !cEncoder.backPropGradient((CBufferFloat*)NULL, NULL, LatentLayer, true)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
初始训练是这样进行的:根据市场对所选行动的实际反应。
但这一过程并未就此结束。这正是该方法的第二部分 — 监督学习发挥作用之处。系统不仅依赖于市场的实际反应,还利用了真实交易中无法获得的信息:对未来的了解。对于每种状态,都会计算出一个近乎完美的参考轨迹 — 即根据后续价格走势判断,能带来最佳结果的动作。这一行动会被传递给 Actor,由其与自身的策略进行比较。通过这种方式,我们不仅通过试错来训练策略,还为其设定了一个明确的基准目标。这种双层方案(实际控制和理想化控制)加速了学习过程,并使模型的行为更加稳定和理性。
//--- Oracul if(!Action.AssignArray(target_action)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; } reward = CheckAction(Action, equity, posit - NForecast + 1) / EtalonBalance; if(!cActor.backProp(Action, GetPointer(cEncoder), LatentLayer) || !cEncoder.backPropGradient((CBufferFloat*)NULL, NULL, LatentLayer, true)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
参考行动的使用不仅限于训练 Actor。我们还将此行动传递给 Critic 的输入端,与第一轮不同,它评估的是已知正确的行为。在前向传播之后,使用此理想化行动将误差反向传播到 Encoder。这使得 Critic 能够更准确地捕捉奖励函数的结构,获取关于状态空间中那些区域(在这些区域中,Agent 的行为必须特别精确)的额外信息,从而在后续训练 Actor 时提高其有效性。
if(!cCritic.feedForward(Action, 1, false, GetPointer(cEncoder), LatentLayer)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; } if(!Result.Update(0, float(reward))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; } if(!cCritic.backProp(Result, GetPointer(cEncoder), LatentLayer) || !cEncoder.backPropGradient((CBufferFloat*)NULL, NULL, LatentLayer, true)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; } //---
这完成了单个状态的完整训练周期。此外,学习同时沿着两条轨迹进行:通过实际行为和参考行为。这种双环结构不仅使误差能够更快地减小,还有助于形成对市场噪声和局部波动具有韧性的内部表征。
在整个过程中,终端会按照指定的频率更新可视化报告。屏幕显示所有模型的平均误差。这些数据使开发人员能够实时监控训练进度,评估过程的稳定性,并识别潜在的故障或异常。
if(GetTickCount() - ticks > 500) { double percent = (epoch + 1.0 - double(posit - end) / (start - end - HistoryBars - NForecast)) / Epochs * 100.0; string str = ""; for(uint f = 0; f < caForecast.Size(); f++) str += StringFormat("%-12s%d %6.2f%% -> Error %15.8f\n", "Forecast", f, percent, caForecast[f].getRecentAverageError()); str += StringFormat("%-12s %6.2f%% -> Error %15.8f\n", "Actor", percent, cActor.getRecentAverageError()); str += StringFormat("%-12s %6.2f%% -> Error %15.8f\n", "Critic", percent, cCritic.getRecentAverageError()); Comment(str); ticks = GetTickCount(); } } }
所有周期的训练完成后,算法就会对结果进行总结 — 显示每个模型的平均误差。之后,EA 将关闭。
Comment(""); //--- for(uint f = 0; f < caForecast.Size(); f++) PrintFormat("%s -> %d -> %-15s%d %10.7f", __FUNCTION__, __LINE__, "Forecast", f, caForecast[f].getRecentAverageError()); PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Actor", cActor.getRecentAverageError()); PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Critic",cCritic.getRecentAverageError()); ExpertRemove(); //--- }
最终,Train 方法展示了如何通过同时依赖实际行动结果和从未来推导出的理想参考来构建交易策略的训练。这种架构不仅提供了对过去数据的适应,而且是一种有意识地向潜在利润的迈进。
本文附件中包含了此 EA 以及本文编写过程中使用的所有程序的完整代码。
测试
如前所述,整个模型训练过程分为两个连续的阶段。在第一阶段,我们使用 2024 年全年 H1 时间周期下 EURUSD 货币对的历史数据进行离线训练。这一时期涵盖了广泛的市场场景 — 从长期横盘整理到快速趋势波动,从低迷、休眠期到波动性的爆炸性飙升。这种多样性使模型能够应对典型和非典型情况,这对于模型的可靠性和通用性至关重要。
在训练过程中,Encoder 学会了识别市场信息中的重复模式,并将其压缩成一种紧凑且特征丰富的表示形式。这种内部市场状态成为 Actor 的基础,它利用来自 Critic 的反馈,形成了一种能够在不同条件下有效运作的稳健策略。此外,在离线训练阶段,模型获得了所谓的近乎完美轨迹 — 基于未来价格走势知识生成的参考动作。这些提示不仅引导策略实现更高利润,还为构建策略提供了可靠的参考点,尤其是在训练的早期阶段,此时模型自身的经验仍然有限。
完成离线训练后,我们进入了第二阶段 — 在接近真实市场环境的条件下进行在线微调。训练在 MetaTrader 5 策略测试器中进行,模型以流式模式逐步逐根 K 线分析市场。这不仅使我们能够测试模型对噪声、市场扭曲和随机波动的抵御能力,而且它也成为了一个重要的适应工具:模型并非简单地记忆,而是真正学会了在真实、不可预测的条件下运行。这种方法显著提高了模型的稳健性,减少了过拟合,并提高了其泛化能力。
最后一步是在全新数据上测试模型 — 即 2025 年 1 月至 3 月期间的市场报价。训练过程中使用的所有参数和内部设置均保持不变。因此,所得结果不仅可用于客观评估所提方法的准确性,还可评估其实用可靠性。


模型测试结果客观地展示了其在训练样本之外的实际有效性和稳健性。初始存款 100 美元增长至 1,087.74 美元,相当于资本增长了 10 倍以上 — 乍一看,这一结果似乎令人印象深刻。然而,仔细审视后,一个明显的模式变得清晰起来:模型在测试期初期表现非常出色,但随着其与训练样本的差距越来越大,其表现开始下降。
余额曲线图清晰地展示了这一效应。测试期前三分之一增长迅速,收益曲线接近参考轨迹,回撤很小且收益稳步增加。然而,从 2 月中旬开始,尤其是 3 月份,曲线趋于平稳,甚至出现了恶化的迹象 — 波动加剧,亏损期延长,利润率显著下降。
这一效应也得到了数值指标的证实。恢复因子(Recovery Factor)小于 1(0.96),这表明利润与回撤深度的比率不太理想。净值的相对回撤达到 65.59% — 这是一个相当高的风险水平,尤其考虑到测试开始时的绝对回撤接近于零。利润因子(Profit Factor)为 1.12 — 这是旨在持续运营的策略的最低可接受值。盈利交易和亏损交易之间的平衡也与该模型不符:盈利交易占比不到 48%。
这种现象可以用训练样本规模有限来合理解释。该模型仅使用 2024 年的数据进行训练,虽然这些数据代表了不同的市场阶段,但市场仍然是一个动态且波动较大的实体。随着模型逐渐远离已知时期,它会越来越多地遇到自己不熟悉的情况。在没有额外训练集的情况下,其行为会变得越来越不相关,尤其是在新的、易变的条件下。
解决这个问题的一个显而易见的方法是扩大训练样本。将往年的数据(例如,2020 年甚至 2015 年的数据)纳入其中,可以使模型覆盖更广泛的市场场景。这不仅提供了泛化能力,而且有助于更好地理解罕见但至关重要的价格行为模式。另一种选择是渐进式在线学习,随着新数据的到来,定期更新模型的权重,这样无需完全重新训练,也能使模型保持最新状态。
结论
在我们的工作中,我们证明了 GinAR 框架在真实市场条件下的实际适用性。从架构构建和分阶段训练到最终测试,整个过程证实了所提出方法的可行性。
尽管在测试期初期表现强劲,但随后的动态揭示了模型固有的局限性,这些局限性源于训练样本范围有限。这凸显了处理时间序列的一个基本原则:策略的稳健性直接取决于其将知识推广到训练情境之外的能力。
所得结果为进一步的研究和完善奠定了坚实的基础。特别是,一个显而易见的进一步发展方向是扩大训练数据的规模并实施持续学习机制。所有这些都为构建真正具有适应性和智能化的交易系统提供了机会,这些系统不仅能够生存下来,而且在充满挑战的市场条件下还能持续盈利。
参考
本文中用到的程序
| # | 名称 | 类型 | 描述 |
|---|---|---|---|
| 1 | Study.mq5 | EA | 离线模型训练的 EA |
| 2 | StudyOnline.mq5 | EA | 在线模型训练的 EA |
| 3 | Test.mq5 | EA | 模型测试 EA |
| 4 | Trajectory.mqh | 类库 | 用于描述系统状态和模型架构的结构 |
| 5 | NeuroNet.mqh | 类库 | 用于创建神经网络的类库 |
| 6 | NeuroNet.cl | 程序库 | OpenCL 程序的代码库 |
本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/18914
注意: MetaQuotes Ltd.将保留所有关于这些材料的权利。全部或部分复制或者转载这些材料将被禁止。
本文由网站的一位用户撰写,反映了他们的个人观点。MetaQuotes Ltd 不对所提供信息的准确性负责,也不对因使用所述解决方案、策略或建议而产生的任何后果负责。
交易中的神经网络:以分解替代模型规模扩张(SSCNN)
价格行为分析工具包开发(第三十八部分):基于Tick环形缓冲区的VWAP与短窗口失衡引擎
基于平均K线(Heikin Ashi)搭建专业交易系统(第一部分):自定义指标开发
神经网络在交易中的应用:一种端到端多变量时间序列预测模型(关键组件)
此外,NeuroNet.mqh 模块中也存在同样的问题
修正后的库收录于文章《交易中的神经网络:分解而非缩放——模块构建》——MQL5相关文章
文章《交易中的神经网络:分解而非缩放——模块构建》 中的修正代码库- MQL5 相关文章