English Русский Español Deutsch 日本語 Português
preview
神经网络在交易中的应用:市场异常的自适应检测(终篇)

神经网络在交易中的应用:市场异常的自适应检测(终篇)

MetaTrader 5交易系统 |
31 0
Dmitriy Gizlyk
Dmitriy Gizlyk

概述

在上一篇文章中,我们探讨了 DADA 框架(自适应瓶颈和双对抗解码器)的理论基础,该框架旨在利用深度学习技术检测时间序列中的异常情况。该工具能够进行有效的数据分析,并识别异常市场状况,这在高度波动的环境中尤为重要。通过采用自适应信息处理方法,该模型能够灵活适应不断变化的市场动态,使其成为分析各种时间序列的通用解决方案。

DADA 框架的架构围绕三个关键组件构建,每个组件都有其独特的用途。第一个是自适应瓶颈模块,它可以动态调整应用于输入数据的压缩级别。这种方法有助于保留市场数据的最重要特征,同时最大限度地减少可能降低分析准确性的信息损失。与具有固定压缩参数的传统模型不同,该系统能够实时适应当前的市场状况。

第二个主要组成部分是一对对抗解码器。第一个解码器重建了正常的市场状态,使模型能够更好地捕捉典型的行为模式。第二个解码器侧重于异常数据,从而能够清晰地区分标准场景和异常场景。这种双解码器设置减少了误报,并提高了模型的总体稳健性。

第三个关键要素是分块和掩码机制,它在处理时间序列数据中发挥着至关重要的作用。该机制使模型能够动态地突出关键片段,抑制噪声,并提高数据表示的质量。分块技术将数据分解成更小的片段,使模型能够更有效地分析局部模式。随机掩码可增强训练效果。它迫使模型重建隐藏部分,从而学习数据中的潜在依赖关系。这提高了它检测复杂结构和模式的能力。这些技术共同提高了异常检测的准确性,并使模型对市场波动更具韧性。掩码还可以提高泛化能力,防止过度拟合特定市场数据部分。

DADA 的主要优势之一是其适应性。与传统算法在市场条件变化时需要重新训练不同, DADA 可以自动调整其参数。在高频交易中,这一点尤为重要,因为交易决策必须在几分之一秒内做出。动态适应能力使该模型能够在从稳定趋势到波动性骤增的各种市场场景中有效运行。

下面展示的是 DADA 框架的原始可视化图。

在上一篇文章的实践部分,我们实现了一个多窗口卷积层对象 CNeuronMultiWindowsConvOCL 。值得注意的是,该组件并非直接源自 DADA 架构的原始描述。然而,在我们的实现中,它在自适应瓶颈模块中发挥着关键作用。具体来说,它能够动态调整数据压缩级别。


自适应瓶颈模块

下一个重要步骤是实际构建自适应瓶颈模块。该模块是一个功能强大的工具,可以动态处理输入数据,从而有效地分析复杂的时间序列并检测其行为中的异常情况。

如前所述,它在概念上类似于混合专家模型MoE )模块,该模块之前已在 CNeuronMoE 对象中实现。这两种方法都依赖于多个较小的模型并行运行来分析输入数据。系统会根据上下文动态选择最相关的 k 个小型模型来处理每个片段。通过将计算集中在最相关的模式上,这既提高了适应性,也提高了准确性。

自适应瓶颈的核心特征在于使用一组自编码器作为这些小型模型,每个模型在潜在空间中具有不同的压缩级别。这使得模型能够灵活适应不断变化的条件,并根据时间序列的特性调整数据表示中的细节层次。因此,自适应瓶颈模块有效地减少了冗余,抑制了噪声,提高了异常模式的检测能力。

我们对自适应瓶颈的实现是建立在 CNeuronAdaBN 对象之上的。正如预期的那样,它继承自 CNeuronMoE 。这种方法使我们能够重用混合专家模型的关键机制,例如动态负载分配和自适应专家选择,这两者都与自适应瓶颈概念自然契合。这一点体现在新对象的结构中。

class CNeuronAdaBN   :  public CNeuronMoE
  {
public:
                     CNeuronAdaBN(void) {};
                    ~CNeuronAdaBN(void) {};
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                          uint window, uint window_out, uint units_count,
                          uint &bottlenecks[], uint top_k, uint variables,
                          ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual int       Type(void) override   const   {  return defNeuronAdaBN; }
  };

如您所见,我们仅重写了初始化方法,而没有引入额外的内部对象。

回想一下,父类 CNeuronMoE 包含一个用于选择前 k 个专家的门控机制 ( cGates ),以及一个包含指向内部模型对象的指针的动态数组 ( cExperts )。乍一看,并行专家的概念似乎与传统顺序模型相悖。然而,我们使用的是一系列能够并行处理独立序列的卷积层。这使我们能够构建可并行运行的专用小型 MLP ,每个小型 MLP 都有自己的可训练参数。

这种架构选择显著提高了适应性,因为每个专家都专注于自己的子任务,从而能够更有效地检测时间序列数据中的复杂模式。此外,动态选择机制确保只有最相关的专家参与分析,从而提高预测准确性。

class CNeuronMoE  :  public CNeuronBaseOCL
  {
protected:
   CNeuronTopKGates     cGates;
   CLayer               cExperts;
   //---
   ..........
   ..........
   ..........
  };

选择前 k 位专家的继承机制完全满足 CNeuronAdaBN 模块的要求。所以我们直接重复使用,没有进行修改。我们只需用一组专为我们的任务定制的新对象来填充继承的动态数组。父类还负责处理前向和后向传播,因此在这方面不需要额外的实现。

内部对象的顺序在 Init 方法中定义,我们重写了该方法。

bool CNeuronAdaBN::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                        uint window, uint window_out, uint units_count,
                        uint &bottlenecks[], uint top_k, uint variables,
                        ENUM_OPTIMIZATION optimization_type, uint batch)
  {
   if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, window * units_count * variables,
                                                                 optimization_type, batch))
      return false;

与往常一样,方法参数包含一组常量,这些常量定义了类的架构。其中,一个关键参数是瓶颈数组,它指定了所创建自编码器的潜在维度。

在该方法内部,我们首先调用基础全连接神经网络层的初始化方法。回想一下,这个类是我们库中所有神经层对象的共同祖先。我们有意跳过直接父类的初始化,因为我们不想初始化其专家池。然而,这一决定要求我们手动初始化所有继承的组件。

在成功初始化基础接口后,我们继续初始化从父类继承的组件。首先,我们初始化负责选择前 k 个专家的模块。专家总数取决于 bottlenecks 数组的大小。其余参数由外部程序传递。

   int index = 0;
   if(!cGates.Init(0, index, OpenCL, window, units_count * variables, bottlenecks.Size(), top_k, optimization, iBatch))
      return false;

接下来,我们准备一个动态数组和局部变量,用于临时存储指向自适应瓶颈模块内部对象的指针。

   cExperts.Clear();
   cExperts.SetOpenCL(OpenCL);
   CNeuronConvOCL *conv = NULL;
   CNeuronMultiWindowsConvOCL *mwconv = NULL;
   CNeuronTransposeRCDOCL *transp = NULL;

然后,我们运行一个循环来计算自编码器中所有潜在状态的总大小。

   uint bn_size = 0;
   for(uint i = 0; i < bottlenecks.Size(); i++)
      bn_size += bottlenecks[i];

准备工作完成后,我们继续构建自编码器组件的序列。

有趣的是,我们创建的第一层是一个标准的卷积层。乍一看,考虑到自适应瓶颈架构的复杂性,这似乎是一个不同寻常的选择。然而,它在处理流程中发挥着重要作用。

关键思想在于,该卷积层中的卷积核数量设定为等于自编码器所有潜在维度的总和。由于每个自编码器处理的都是相同的输入数据,因此压缩程度取决于其潜在大小。每个卷积核独立运行,在输出缓冲区中产生单个元素。从概念上讲,这可以被视为大量的小型模型将输入压缩为单个标量值。这些输出可以按照任意大小进行分组,对应于不同自编码器的潜在空间。唯一的限制是元素的总数保持不变。

   index++;
   conv = new CNeuronConvOCL();
   if(!conv ||
      !conv.Init(0, index, OpenCL, window, window, bn_size, units_count, variables, optimization, iBatch) ||
      !cExperts.Add(conv))
     {
      delete conv;
      return false;
     }
   conv.SetActivationFunction(SoftPlus);

我们将在训练过程中使用这一特性。接下来,我们将介绍多窗口卷积层。每个卷积窗口处理其自身的一组元素,形成特定自编码器的潜在表示。然后,它将数据重构到所需的大小。

   index++;
   mwconv = new CNeuronMultiWindowsConvOCL();
   if(!mwconv ||
      !mwconv.Init(0, index, OpenCL, bottlenecks, window_out, units_count, variables, optimization, iBatch) ||
      !cExperts.Add(mwconv))
     {
      delete conv;
      return false;
     }
   mwconv.SetActivationFunction(SoftPlus);

然后,我们在自编码器的解码器部分添加另一层。每个自编码器都必须拥有自己的层,且该层具有唯一的可训练参数。然而,多窗口卷积层的输出可以看作是一个 4D 张量:[变量单元自编码器维度]。标准卷积层不允许我们为每个自编码器分配唯一的参数。然而,通过先设置自编码器的维度,可以轻松实现这一点。接下来,我们声明一个数据转置层。

   transp = new CNeuronTransposeRCDOCL();
   index++;
   if(!transp ||
      !transp.Init(0, index, OpenCL, units_count * variables, bottlenecks.Size(), window_out, optimization, iBatch) ||
      !cExperts.Add(transp))
     {
      delete transp;
      return false;
     }
   transp.SetActivationFunction((ENUM_ACTIVATION)conv.Activation());

之后,我们添加一个卷积层,并指定变量数量等于自编码器的数量。这会使该层为每个自编码器初始化独立的权重矩阵。

   index++;
   conv = new CNeuronConvOCL();
   if(!conv ||
      !conv.Init(0, index, OpenCL, window_out, window_out, window, units_count * variables, bottlenecks.Size(),
                                                                                       optimization, iBatch) ||
      !cExperts.Add(conv))
     {
      delete conv;
      return false;
     }
   conv.SetActivationFunction(None);

最后,我们添加另一个转置层来恢复原始数据布局。

   transp = new CNeuronTransposeRCDOCL();
   index++;
   if(!transp ||
      !transp.Init(0, index, OpenCL, bottlenecks.Size(), units_count * variables, window, optimization, iBatch) ||
      !cExperts.Add(transp))
     {
      delete transp;
      return false;
     }
   transp.SetActivationFunction((ENUM_ACTIVATION)conv.Activation());
//---
   return true;
  }

至此,该方法就完成了。在返回之前,我们会将一个布尔结果传递给调用程序,以表明执行成功。

以上就是我们关于自适应瓶颈模块实现方法的讨论。如前所述,前向和后向传播是由父类的继承功能来处理的。此对象及其所有方法的完整源代码见附加材料。


模型架构

在此迭代中,我们选择不将整个 DADA 框架打包成一个单一的整体对象。相反,我们依赖于库中经过充分测试的组件,整合之前开发的自适应瓶颈模块,并将所有内容组装成一个灵活的线性架构,用于可训练模型。

这种方法给了我们更大的自由度。首先,它使系统更加模块化 — 各个组件可以被替换或优化,而无需重写整个代码库。其次,它消除了对编码器和解码器设计的限制。我们不再受限于僵化的结构,可以自由实验,根据特定任务调整模型,并寻找最优配置。

这种模块化设计使得模型描述变得稍微复杂一些,但为了灵活性和适应性,这是合理的权衡。

在本实验中,我们同时训练三个模型,每个模型在整体分析和决策系统中都发挥着关键作用。这一配置形成了一个多层次的智能系统,能够分析数据、适应不断变化的市场动态、预测价格走势,并根据综合数据分析来优化策略。

第一个模型是环境状态编码器,它基于 DADA 框架架构,并带有正常状态解码器。它被训练为一个经典的自编码器,其主要目标是从潜在空间中以最小的损失重构输入数据。这使得系统能够在保留环境基本特征的同时,学习到最具信息量的数据压缩表示。除了简单的降维之外,该机制还能揭示数据中隐藏的依赖关系——这对于构建高精度分析模型至关重要。

第二个关键组件是 Actor ,它取代了原始 DADA 架构中的异常解码器。该模块负责与市场环境的主动交互。其主要目标是识别稳定趋势、检测潜在反转点,并做出优化交易策略的决策。

Actor 处理多维输入数据,识别重复出现的模式和不断变化的市场动态,并根据这些洞察生成交易信号。这使得该系统从纯粹的分析工具转变为自适应工具 — 不仅能够理解数据,还能响应变化并确定最佳的进入点和退出点。

然而,即便拥有强大的分析和信号生成机制,预测的准确性仍然至关重要。这就是第三个模型发挥作用的地方。其主要功能是预测未来价格走势最可能的方向。该组件是对 Actor 的补充,可作为交易决策的额外筛选条件。

所有三个模型的架构都在 CreateDescriptions 方法中定义。该方法接收三个指向动态数据缓冲区的指针,我们在其中构建模型的架构描述。

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
   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;
     }

接下来,我们遵循 DADA 框架,进行分块和掩码操作。对于 20% 数据的随机掩码,我们使用一个 Dropout 层。

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronDropoutOCL;
   descr.count = prev_count;
   descr.probability = 0.2f;
   descr.activation = None;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

在最初的 DADA 方法中,分块是对单元序列进行操作的。在我们的情况下,我们会对数据进行转置,以创建处理此类序列的最佳条件。

//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronTransposeOCL;
   descr.window = BarDescr;
   prev_count = descr.count = HistoryBars;
   descr.activation = SoftPlus;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

然后,我们使用卷积层来独立处理给定大小的片段。具体来说,我们应用了两个连续的卷积层,这两个层并行地对非重叠片段进行编码,并作为环境模型的编码器。

//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   descr.window = HistoryBars / Segments;
   prev_count = descr.count = (HistoryBars + descr.window - 1) / descr.window;
   descr.step = descr.window;
   descr.layers = BarDescr;
   descr.activation = SoftPlus;
   int prev_wout = descr.window_out = EmbeddingSize;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 5
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   descr.count = prev_count;
   descr.window = prev_wout;
   descr.step = prev_wout;
   prev_wout = descr.window_out = EmbeddingSize;
   descr.layers = BarDescr;
   descr.activation = TANH;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

接下来是自适应瓶颈模块,在该模块中,我们创建了 15 个小型自编码器,其潜在维度是 8 的倍数。对于每个片段,我们选择 3 个最合适的自编码器进行编码。

//--- layer 6
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronAdaBN;
   descr.window = prev_wout;
   descr.count = prev_count;
   descr.window_out = 256;
   descr.step = 3; // Top K
   descr.layers = BarDescr; // Variables
     {
      int temp[15];
      for(uint i = 0; i < temp.Size(); i++)
         temp[i] = int(i + 1) * 8;
      if(ArrayCopy(descr.windows, temp) < (int)temp.Size())
         return false;
     }
   descr.batch = 1e4;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

为了重建这些片段,我们使用了一个解码器,该解码器由两个连续的卷积层实现,与编码器的结构相对应。

//--- layer 7
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   descr.count = prev_count * BarDescr;
   descr.window = prev_wout;
   descr.step = prev_wout;
   prev_wout = descr.window_out = EmbeddingSize / 2;
   descr.activation = SoftPlus;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 8
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   descr.count = prev_count * BarDescr;
   descr.window = prev_wout;
   descr.step = prev_wout;
   prev_wout = descr.window_out = HistoryBars / Segments;
   descr.activation = TANH;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

请注意,解码器的输出使用了双曲正切(tanh)激活函数。这是有意为之。经过批量归一化处理后,数据以零为中心,方差接近一。根据三西格玛法则,正态分布中约 68% 的值落在平均值的一个标准差范围内。标准差为 1,这对应于范围 [-1, 1]。这正好是 tanh 函数的取值范围。这确保了解码器输出保持在最可能值的范围内,同时抑制异常值。

为了评估重建质量,我们将解码器输出与原始数据进行比较。由于我们之前已对数据进行转置处理,因此我们首先通过逆转置将其恢复为原始格式。

//--- layer 9
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronTransposeOCL;
   descr.count = BarDescr;
   descr.window = HistoryBars;
   descr.activation = None;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

我们将所得值返回到原始数据的分布中。

//--- layer 10
   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;
     }

剩下的两个模型基于自适应瓶颈模块生成的潜在表示进行操作。因此,我们将此模块的参数存储在一个局部变量中。

//--- Latent
   CLayerDescription *latent = encoder.At(LatentLayer);
   if(!latent)
      return false;

接下来,我们定义 Actor 的架构。Actor 在当前账户余额和未平仓头寸的背景下分析环境,然后确定最佳交易操作。为了实现这一点,该模型以描述账户状态的向量作为输入。我们首先使用全连接层处理这个输入。

//--- Actor
   actor.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = AccountDescr;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!actor.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(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

然后,我们应用一个拼接层,将两个数据源结合起来:账户状态和来自编码器潜在空间的压缩环境表示。这使得模型在做出决策时,能够同时考虑财务指标和抽象的环境特征。

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConcatenate;
   descr.count = LatentCount;
   descr.window = prev_count;
   descr.step = latent.count * latent.window * latent.layers;
   descr.batch = 1e4;
   descr.activation = SoftPlus;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

组合后的数据通过一个由三个全连接层组成的决策模块。该模块提取关键模式并生成最终的动作输出。

//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = LatentCount;
   descr.batch = 1e4;
   descr.activation = TANH;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = LatentCount;
   descr.activation = TANH;
   descr.batch = 1e4;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 5
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = NActions;
   descr.activation = SIGMOID;
   descr.batch = 1e4;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

负责预测未来价格变动概率的第三个模型,其架构最为简单。它仅基于环境状态的压缩表示进行操作。我们首先使用一个全连接层来处理潜在输入。

   probability.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = latent.count * latent.window * latent.layers;
   descr.activation = latent.activation;
   descr.optimization = ADAM;
   if(!probability.Add(descr))
     {
      delete descr;
      return false;
     }

其后是一个决策模块,由三个全连接层组成。

//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = LatentCount;
   descr.activation = TANH;
   descr.batch = 1e4;
   descr.optimization = ADAM;
   if(!probability.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = LatentCount;
   descr.activation = TANH;
   descr.batch = 1e4;
   descr.optimization = ADAM;
   if(!probability.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = NActions / 3;
   descr.activation = SoftPlus;
   descr.batch = 1e4;
   descr.optimization = ADAM;
   if(!probability.Add(descr))
     {
      delete descr;
      return false;
     }

最后,使用 SoftMax 函数将输出转换为概率。

//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronSoftMaxOCL;
   prev_count = descr.count = prev_count;
   descr.step = 1;
   descr.activation = None;
   descr.batch = 1e4;
   descr.optimization = ADAM;
   if(!probability.Add(descr))
     {
      delete descr;
      return false;
     }
//---
   return true;
  }

附件中提供了模型的完整架构描述。


模型的训练

定义完模型架构后,我们将进入下一个阶段 — 训练。训练算法在 EA “...\DADA\Study.mq5” 中实现。由于我们需要并行训练三个模型,因此需要对 EA 的逻辑进行一些调整。本文不会详细讲解全部代码。我们将重点关注核心训练方法 Train

在该方法开始时,我们通过声明几个局部变量来进行一些基本设置。

void Train(void)
  {
//---
   vector<float> probability = vector<float>::Full(Buffer.Size(), 1.0f / Buffer.Size());
//---
   vector<float> result, target, state;
   matrix<float> fstate = matrix<float>::Zeros(1, NForecast * BarDescr);
   bool Stop = false;
//---
   uint ticks = GetTickCount();

实际的训练是在一个嵌套循环结构中进行的。外层循环对训练批次进行迭代。对于每一批数据,从经验回放缓冲区中随机抽取一条轨迹,并从中选取一个起始点用于训练。

   for(int iter = 0; (iter < Iterations && !IsStopped() && !Stop); iter += Batch)
     {
      int tr = SampleTrajectory(probability);
      int start = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * (Buffer[tr].Total - 2 - NForecast - Batch));
      if(start <= 0)
        {
         iter -= Batch;
         continue;
        }
      if(!Encoder.Clear() ||
         !Actor.Clear())
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         Stop = true;
         break;
        }
      result = vector<float>::Zeros(NActions);

在内层循环中,模型在单个批次内的连续状态上进行训练。

在此需指出,本文框架内开发的模型不包含循环结构。通常,此类模型是在从经验回放缓冲区中提取的完全随机状态下进行训练的。然而,在这种情况下,训练将在“近乎理想”的轨迹上进行,这些轨迹的动作是由该 EA 基于关于环境后续状态的可用信息直接生成的。与实时模型训练不同,在使用重播缓冲区时,我们拥有除最后一条记录外所有存储记录的环境后续状态数据。这使得训练过程能够得到更精确的指导。

但凡事都有两面性。在这种情况下,我们没有关于未平仓位的信息。然而,对我们而言,重要的是不仅要训练模型开仓,还要通过寻找最佳平仓点来管理仓位。因此,在训练过程中,我们将形成小的训练批次,同时也会形成“最优”仓位。

      for(int i = start; i < MathMin(Buffer[tr].Total, start + Batch); i++)
        {
         if(!state.Assign(Buffer[tr].States[i].state) ||
            MathAbs(state).Sum() == 0 ||
            !bState.AssignArray(state))
           {
            iter -= Batch + start - i;
            break;
           }
         //---
         bTime.Clear();
         double time = (double)Buffer[tr].States[i].account[7];
         double x = time / (double)(D'2024.01.01' - D'2023.01.01');
         bTime.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
         x = time / (double)PeriodSeconds(PERIOD_MN1);
         bTime.Add((float)MathCos(x != 0 ? 2.0 * M_PI * x : 0));
         x = time / (double)PeriodSeconds(PERIOD_W1);
         bTime.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
         x = time / (double)PeriodSeconds(PERIOD_D1);
         bTime.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
         if(bTime.GetIndex() >= 0)
            bTime.BufferWrite();
         //--- Account
         float PrevBalance = Buffer[tr].States[MathMax(i - 1, 0)].account[0];
         float PrevEquity = Buffer[tr].States[MathMax(i - 1, 0)].account[1];
         float profit = float(bState[0] / _Point * (result[0] - result[3]));
         bAccount.Clear();
         bAccount.Add(1);
         bAccount.Add((PrevEquity + profit) / PrevEquity);
         bAccount.Add(profit / PrevEquity);
         bAccount.Add(MathMax(result[0] - result[3], 0));
         bAccount.Add(MathMax(result[3] - result[0], 0));
         bAccount.Add((bAccount[3] > 0 ? profit / PrevEquity : 0));
         bAccount.Add((bAccount[4] > 0 ? profit / PrevEquity : 0));
         bAccount.Add(0);
         bAccount.AddArray(GetPointer(bTime));
         if(bAccount.GetIndex() >= 0)
            bAccount.BufferWrite();

在循环体中,我们首先从经验回放缓冲区中提取信息,并为要训练的模型构建源数据对象。之后,我们调用环境状态 Encoder 前馈方法。

         //--- Feed Forward
         if(!Encoder.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CBufferFloat*)NULL))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }

之后,我们调用其他两个模型的类似方法,向它们传递一个指向环境状态 Encoder 对象的指针。

         if(!Actor.feedForward((CBufferFloat*)GetPointer(bAccount), 1, false, GetPointer(Encoder), LatentLayer))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }
         if(!Probability.feedForward(GetPointer(Encoder), LatentLayer, (CBufferFloat*)NULL))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }

接下来是生成“最优交易操作”的过程。这一逻辑完全沿用了之前一篇关于多任务学习的文章。所以我们就不在这里重复了,您可以点击此链接查看完整描述。

一旦目标值准备就绪,我们就会着手优化模型参数,以尽量减少与这些目标的偏差。训练从环境状态 Encoder 开始。对于目标张量,我们向其传递描述分析环境状态的向量,该向量在前馈传递中也已使用。

         //--- State Encoder
         if(!Encoder.backProp(GetPointer(bState), (CBufferFloat*)NULL, NULL))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }

接下来对执行者进行训练,以最小化与“最优交易操作”的偏差。

         //--- Actor Policy
         if(!Actor.backProp(GetPointer(bActions), (CNet*)GetPointer(Encoder), LatentLayer)
            || !Encoder.backPropGradient((CBufferFloat*)NULL, (CBufferFloat*)NULL, LatentLayer, true)
            )
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }

对于第三种模型,我们根据下一个柱的颜色来判断未来价格变动的方向。

         target = vector<float>::Zeros(NActions / 3);
         if(fstate[0, 0] > 0)
            target[0] = 1;
         else
            if(fstate[0, 0] < 0)
               target[1] = 1;
         if(!Result.AssignArray(target) ||
            !Probability.backProp(Result, (CBufferFloat*)NULL)
            || !Encoder.backPropGradient((CBufferFloat*)NULL, (CBufferFloat*)NULL, LatentLayer)
           )
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }

最后,我们记录训练进度,并继续进行循环的下一轮迭代。

         //---
         if(GetTickCount() - ticks > 500)
           {
            double percent = double(iter + i - start) * 100.0 / (Iterations);
            string str = StringFormat("%-13s %6.2f%% -> Error %15.8f\n", "Encoder",
                                         percent, Encoder.getRecentAverageError());
            str += StringFormat("%-14s %6.2f%% -> Error %15.8f\n", "Actor", percent,
                                                     Actor.getRecentAverageError());
            str += StringFormat("%-13s %6.2f%% -> Error %15.8f\n", "Probability", 
                                      percent, Probability.getRecentAverageError());
            Comment(str);
            ticks = GetTickCount();
           }
        }
     }

所有训练迭代完成后,我们将结果输出到日志中,并启动 EA 的关闭过程。

   Comment("");
//---
   PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Actor", Actor.getRecentAverageError());
   PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Probability", Probability.getRecentAverageError());
   ExpertRemove();
//---
  }

EA 的完整源代码在附录中提供,可供独立研究。附件中还包括与环境交互以及测试已训练模型的程序。


测试

MQL5 中实现 DADA 框架概念并将其集成到可训练模型之后,下一个关键步骤是在真实的历史数据上评估它们的性能。这使我们能够评估它们在实际交易条件下的可行性。

为了进行训练,我们使用 MetaTrader 5 策略测试器,基于2024 年的历史 EURUSD 数据(M1 时间周期)生成了一个随机运行数据集。数据是使用标准指标设置收集的,以保证实验条件尽可能干净,并减少外部因素干扰。

然后,使用 2025 年 1 月至 2 月的历史数据对训练好的模型进行测试。所有实验参数均保持不变,以确保对 Actor 学习行为的客观评估。对训练过程中未使用过的数据进行测试是至关重要的验证步骤,因为它能反映出模型在接近真实条件下的表现。

测试结果如下所示。

测试期间,模型共执行 57 笔交易,其中超过 35% 为盈利交易。尽管胜率相对较低,但每笔盈利交易的利润平均值大约是亏损平均值的三倍,从而使该模型实现了总体盈利,利润因子为 1.53。

然而,值得注意的是,大部分利润是在 1 月上半月产生的。在测试期的剩余时间里,资产净值曲线在较窄的范围内波动。这表明可能需要对模型进行进一步优化。

还应注意的是,在实现过程中对 DADA 架构进行了多项修改。这意味着结果仅适用于此特定版本的 DADA 框架。


结论

在本研究中,我们探索了 DADA 框架,该框架通过将自适应瓶颈与双并行解码器相结合,提出了一种创新方法,以实现更准确的时间序列分析。这种方法的一个关键优势在于,它能够动态适应不同的数据结构,无需事先调整。

我们使用 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/17577

附加的文件 |
MQL5.zip (2565.75 KB)
挖掘央行资产负债表数据,描绘全球流动性全貌 挖掘央行资产负债表数据,描绘全球流动性全貌
挖掘各国央行资产负债表数据,能够厘清外汇市场与主要币种的全球流动性现状。我们整合美联储、欧洲央行、日本央行、中国人民银行的数据构建综合指数,并借助机器学习挖掘潜藏规律。该方法融合基本面与技术分析,将原始数据转化为可落地的交易信号。
面向外汇市场的CAPM模型指标 面向外汇市场的CAPM模型指标
在MQL5中实现面向外汇市场的经典CAPM模型适配。本指标基于历史波动率计算预期收益率与风险溢价。指标会在价格高点与低点处出现明显抬升,反映资产定价的基本原理。可实际应用于逆势策略与趋势跟踪策略,实时考量风险收益比的动态变化。本文包含相关数学原理与技术实现代码。
新手在交易中的10个基本错误 新手在交易中的10个基本错误
新手在交易中会犯的10个基本错误: 在市场刚开始时交易, 获利时不适当地仓促, 在损失的时候追加投资, 从最好的仓位开始平仓, 翻本心理, 最优越的仓位, 用永远买进的规则进行交易, 在第一天就平掉获利的仓位,当发出建一个相反的仓位警示时平仓, 犹豫。
神经网络在交易中的应用:市场异常的自适应检测(DADA) 神经网络在交易中的应用:市场异常的自适应检测(DADA)
我们诚邀您了解 DADA 框架,这是一种用于检测时间序列异常的创新方法。它有助于区分随机波动和可疑偏差。与传统方法不同,DADA 具有灵活性,能够适应不同的数据。它没有采用固定的压缩级别,而是提供了多种选项,并为每种情况选择最合适的选项。