English Русский Español Deutsch 日本語 Português
preview
神经网络变得轻松(第四十六部分):条件导向目标强化学习(GCRL)

神经网络变得轻松(第四十六部分):条件导向目标强化学习(GCRL)

MetaTrader 5交易系统 | 23 一月 2024, 09:45
875 0
Dmitriy Gizlyk
Dmitriy Gizlyk

概述

“条件导向目标强化学习” 听起来有点不同寻常,甚至很奇怪。 毕竟,强化学习的基本原则旨在令代理者与环境交互期间的总奖励最大化。 但按上下文,我们正在观察在特定阶段或特定场景内达成特定目标。

我们曾讨论过将总体目标分解为子任务的益处,并探索了向代理者传授有助于达成整体结果的不同技能的方法。 在本文中,我提议从不同的角度来看待这个问题。 意即,我们应当训练一位代理者来独立选择策略,以及达成特定的子任务的技能。


1. GCRL 特征

条件导向目标强化学习(GCRL)是一组复杂的强化学习问题。 我们训练代理者在某些场景下达成不同的目标。 以前,我们训练代理者根据环境的当前状态来选择一个或另一个动作。 在 GCRL 的情况下,我们希望以这样一种方式训练代理者,即其动作不仅由当前状态判定,而且还涉及此阶段的特定子任务。 换言之,除了描述当前状态的向量之外,我们还应该以某种方式向代理者指明在每个特定时刻要达成的子任务。 这与训练技能的任务非常相似,即我们在每个时刻向代理者指示一项技能之时。 毕竟,指示使用“开仓技能”或“开仓任务”似乎是在玩文字游戏。 但这些话的背后是代理者训练方法的差异。

在强化学习中,瓶颈始终是奖励函数。 就像在传统的强化训练中一样,在技能训练任务中使用单一目标奖励函数。 指示所用技能应能补足环境状态,并有助于代理者导航环境。

在使用 GCRL 方式时,我们会引入特定的子任务。 它们的成就应该反映在代理者收到的奖励当中。 它类似于判别器的内部奖励,但基于旨在达成特定目标(解决子任务)的明确可衡量指标。

为了理解其中分寸,我们来看一个按两种方式开仓的示例。 在训练技能时,我们将环境的当前状态,和缺少持仓的账户状态向量传递给调度程序。 调度程序判定所要传递给代理者进行决策的技能描述向量。 如您所忆,我们采用账户余额变化作为奖励。 值得注意的是,我们在整个代理者训练过程中都应用相同的奖励。 更在于,开仓不会立即影响余额变化。 例外则是开仓可能占用的佣金。 但通常是,我们得到延迟开仓的奖励。

对于 GCRL,不光含有全局目标的奖励,我们还引入了针对达成特定子任务的额外奖励。 例如,我们可以针对开仓引入一些奖励,或者与其对比,在代理者开仓之前处以罚款。 在此,我们需要采取一种平衡的方式来形成这种奖励。 它不应超过交易操作本身可能产生的盈利和亏损。 否则,代理者只是简单地开仓并“获得点数”,而账户余额将趋于 0。

此外,奖励应取决于手头的任务。 只有在设置“开仓”任务时,我们才会对开仓进行奖励,并惩罚此类操作的缺失。 在搜索持仓的离场点时,对比开仓,我们可以针对加仓、以及长期持仓进行惩罚。

出于 GCRL,形成手头任务的描述向量时,重要的是要考虑某些需求。 向量应明确指示代理者在特定时间点应达成的子任务。

任务描述向量可以包含各种元素,具体取决于任务的上下文和细节。 例如,在开仓的情况下,描述向量包含的信息也许是有关目标资产、交易量、价格限制、或与开仓相关的其它参数。 这些元素对于代理者来说应该是清晰易懂的,如此他才能正确解释给定的子任务。

此外,任务描述向量应含有充足的信息量,如此这般代理者才能做出相应决策,最大程度地专注于达成该子任务。 这也许需要包含额外的数据或上下文信息,以便帮助代理者更准确地理解如何采取行动来达成目标。

子任务描述向量与期待的结果之间应当存在明显的逻辑关系,但并非数学关系。 我们可以使用常规的独热向量。 向量的每个元素都对应一个单独的子任务。 向量会与环境当前状态的描述一同传递给代理者。 最主要的是,代理者能够清晰地解释子任务,并在子任务和奖励之间建立其内部连接。 有关于此,我们应注意到奖励。 引入的额外奖励应与特定的子任务相匹配。

但还有其它方式也可以形成子任务描述向量。 如果需要众多因素的组合来描述一个单独的子任务,我们能够利用一个单独的模型,通过模仿训练技能的方法来形成这样的向量。 这样的模型能用各种自动编码器、或其它任何可用的方法进行训练。

如您所见,这两种方式都非常强大,可令我们解决不同的问题。 不过,它们每个都有其缺点。 这两种方式之间出现各种协同作用并非巧合,这令构建更稳定的算法成为可能。 毕竟,在学习技能的过程中,我们在环境的当前状态和代理者技能(动作政策)之间构建了依赖关系。 使用旨在达成特定子任务的其它工具,将有助于调整代理者策略,从而获得最优结果。

其中一种方式是自适应变分 GCRL(aVGCRL)。 该思路是,在随机环境中,每个技能的分发表现不会是均匀的。 甚至,它也许会根据环境状态而变化。 在某些状态下,将存在对某些技能的依赖性,而其分发的离散度会最小。 与此同时,在相同状态下使用其它技能的可能性不会那么明显,且它们的分发离散度会明显更高。 在其它环境状态下,技能分发的各异性很可能会大不相同。 如果我们看一下我们在上一篇文章中训练调度器的变分自动编码器的各种隐含表述,就可以观察到这种效果。 一个合乎逻辑的解决方案是专注于显式依赖关系。 aVGCRL 方法的作者建议将每个技能与目标值的偏离误差除以分发的离散度。 显然,方差越小,误差的影响越大,训练过程中相应的权重系数变化越大。 与此同时,其它技能的随机性不会给一般模型带来明显的失衡。


2. 利用 MQL5 实现

我们转入 GCRL 方法的实现,以便更好地掌握它。 我们将为两种所考察的方法创建共生关系,尽管我们会将一切合并到单一模型之中。

在前一篇文章中,我们创建了 2 个模型:变分自动编码器形式的调度器,和代理者。 与以前的方法不同,代理者仅接收自动编码器的隐含状态,根据我们的逻辑,其应该包含所有必要的信息。 测试表明,训练代理者依据自动编码器以便达成状态预测,并不能提供预期的结果。 这也许是由于预测条件的品质不够。

与此同时,使用经典的奖励方法有可能改进使用以前已训练调度训练代理者的过程。

在这步操作中,我们决定放弃单独训练变分自动编码器,并将其编码器直接包含在代理者模型当中。 应当说,这种方式在某种程度上违反了训练自动编码器的原则。 毕竟,使用任何自动编码器的主要思在于不涉及特定任务的情况下进行数据压缩。 但现在,我们面临的任务并非训练编码器,依据相同的源数据解决若干个问题。

此外,我们只往编码器输入中供应环境的当前状态。 在我们的情况下,这些是有关金融产品价格变动的历史数据,和分析指标的参数。 换言之,我们排除了有关帐户状态的信息。 我们假设调度器(在本例中为编码器)将基于历史数据形成所采取的技能。 这可以是在上涨、下跌或横盘行情中的操作政策。

基于有关帐户状态的信息,我们将为代理者创建一个子任务来搜索入场或离场点。

将模型划分为调度器和代理者是绝对任意的。 毕竟,我们将形成一个模型。 不过,如上所述,我们仅向编码器输入里供应历史数据。 这意味着我们必须将有关已分配子任务的信息添加到模型的中部。 我们以前并未这样做过。 这不是一个全新的方案。 我们以前遇到过这种情况。 在这种情况下,我们创建了 2 个模型。

第一部分由一个模型求解,然后我们将第一个模型的输出与新数据相结合,并将其投喂到第二个模型的输入之中。 这种方案更容易安置,但它有一个明显的缺陷。 它会导致主程序和 OpenCL 关联环境之间多余的通信。 我们必须从关联环境中获取第一个模型的结果,并在第二个模型里重新加载它们。 逆向验算过程中的误差梯度也是如此。 使用单一模型可以消除这些操作。 但是,在模型操作的分段添加新信息的问题浮现了。

为了解决这个问题,我们将创建一个新型的神经层 CNeuronConcatenate。 和以前一样,我们在 OpenCL 程序中创建必要的内核来开始操控每个新的神经层类。 首先,我们创建了 Concat_FeedForward 前向验算内核。 所有内核都在基本全连接神经层的类似内核基础上创建的。 主要区别在于为第二条信息流添加了额外的缓冲区和参数。

在 Concat_FeedForward 内核参数中,我们看到一个单独的权重矩阵、2 个源数据张量、一个结果向量、和 3 个数值参数(源数据张量的大小,和激活函数 ID)

__kernel void Concat_FeedForward(__global float *matrix_w,
                                 __global float *matrix_i1,
                                 __global float *matrix_i2,
                                 __global float *matrix_o,
                                 int inputs1,
                                 int inputs2,
                                 int activation
                                )

如前,我们将基于层中的神经元数量在一维任务空间中启动内核,其与结果缓冲区的大小相同。 在内核主体中,我们定义线程 ID,并声明必要的局部变量。 此处,我们判定权重系数缓冲区中的偏移量。 请注意,对于层输出的每个神经元,我们定义的权重数等于 2 个源数据缓冲区和 1 个贝叶斯偏置神经元的总体大小。

  {
   int i = get_global_id(0);
   float sum = 0;
   float4 inp, weight;
   int shift = (inputs1 + inputs2 + 1) * i;

接下来,我们安排一个循环来计算 1 个源数据缓冲区的加权和。 这个过程与完全连接的神经层的内核完全雷同。

   for(int k = 0; k < inputs1; k += 4)
     {
      switch(inputs1 - k)
        {
         case 1:
            inp = (float4)(matrix_i1[k], 0, 0, 0);
            weight = (float4)(matrix_w[shift + k], 0, 0, 0);
            break;
         case 2:
            inp = (float4)(matrix_i1[k], matrix_i1[k + 1], 0, 0);
            weight = (float4)(matrix_w[shift + k], matrix_w[shift + k + 1], 0, 0);
            break;
         case 3:
            inp = (float4)(matrix_i1[k], matrix_i1[k + 1], matrix_i1[k + 2], 0);
            weight = (float4)(matrix_w[shift + k], matrix_w[shift + k + 1], matrix_w[shift + k + 2], 0);
            break;
         default:
            inp = (float4)(matrix_i1[k], matrix_i1[k + 1], matrix_i1[k + 2], matrix_i1[k + 3]);
            weight = (float4)(matrix_w[shift + k], matrix_w[shift + k + 1], matrix_w[shift + k + 2], matrix_w[shift + k + 3]);
            break;
        }
      float d = dot(inp, weight);
      if(isnan(sum + d))
         continue;
      sum += d;
     }

循环迭代完成后,我们按 1 个源数据缓冲区的大小调整权重矩阵中的偏差。 此外,我们针对 2 个源数据缓冲区创建了一个类似的循环。

   shift += inputs1;
   for(int k = 0; k < inputs2; k += 4)
     {
      switch(inputs2 - k)
        {
         case 1:
            inp = (float4)(matrix_i2[k], 0, 0, 0);
            weight = (float4)(matrix_w[shift + k], 0, 0, 0);
            break;
         case 2:
            inp = (float4)(matrix_i2[k], matrix_i2[k + 1], 0, 0);
            weight = (float4)(matrix_w[shift + k], matrix_w[shift + k + 1], 0, 0);
            break;
         case 3:
            inp = (float4)(matrix_i2[k], matrix_i2[k + 1], matrix_i2[k + 2], 0);
            weight = (float4)(matrix_w[shift + k], matrix_w[shift + k + 1], matrix_w[shift + k + 2], 0);
            break;
         default:
            inp = (float4)(matrix_i2[k], matrix_i2[k + 1], matrix_i2[k + 2], matrix_i2[k + 3]);
            weight = (float4)(matrix_w[shift + k], matrix_w[shift + k + 1], matrix_w[shift + k + 2], matrix_w[shift + k + 3]);
            break;
        }
      float d = dot(inp, weight);
      if(isnan(sum + d))
         continue;
      sum += d;
     }

在内核的末尾,我们添加一个贝叶斯偏差元素,并激活结果总和。 然后,我们将结果值保存在结果缓冲区的相应元素之中。

   sum += matrix_w[shift + inputs2];
//---
   if(isnan(sum))
      sum = 0;
   switch(activation)
     {
      case 0:
         sum = tanh(sum);
         break;
      case 1:
         sum = 1 / (1 + exp(-sum));
         break;
      case 2:
         if(sum < 0)
            sum *= 0.01f;
         break;
      default:
         break;
     }
   matrix_o[i] = sum;
  }

在修改后向验算内核,并更新权重矩阵时,所用方式完全相同。 您可以在 NeuroNet_DNG\NeuroNet.cl(已加到文章中)中掌握它们。

创建内核后,我们继续在主程序中操控 CNeuronConcatenate 类的代码。 类方法的集合都是十分标准的:

  • CNeuronConcatenate 构造函数和 ~CNeuronConcatenate 析构函数
  • 初始化 Init 神经层
  • feedForward 正向验算
  • calcHiddenGradients 误差梯度分发
  • 更新 updateInputWeights 权重矩阵
  • 类型对象标识
  • 调用 “Save” 和 “Load” 操控文件。

class CNeuronConcatenate   :  public CNeuronBaseOCL
  {
protected:
   int               i_SecondInputs;
   CBufferFloat     *ConcWeights;
   CBufferFloat     *ConcDeltaWeights;
   CBufferFloat     *ConcFirstMomentum;
   CBufferFloat     *ConcSecondMomentum;

public:
                     CNeuronConcatenate(void);
                    ~CNeuronConcatenate(void);
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint numNeurons, 
                          uint inputs1, uint inputs2, ENUM_OPTIMIZATION optimization_type, uint batch);
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput);
   virtual bool      calcHiddenGradients(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput, CBufferFloat *SecondGradient);
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput);
   //--- methods for working with files
   virtual bool      Save(int const file_handle);
   virtual bool      Load(int const file_handle);
   //---
   virtual int       Type(void)        const                      {  return defNeuronConcatenate; }
   virtual void      SetOpenCL(COpenCLMy *obj);
  };

此外,在类中,我们声明了一个变量来记录其它源数据的大小,以及 4 个数据缓冲区:用于优化权重系数的各种方法的权重和动量矩阵。 新的缓冲区将用于安排与先前神经层和新源数据的通信过程。 数据传输至后续神经层,是由全连接的 CNeuronBaseOCL 神经层的父类来安排。

我们在类的构造函数中初始化数据缓冲区。

CNeuronConcatenate::CNeuronConcatenate(void) : i_SecondInputs(0)
  {
   ConcWeights = new CBufferFloat();
   ConcDeltaWeights = new CBufferFloat();
   ConcFirstMomentum = new CBufferFloat();
   ConcSecondMomentum = new CBufferFloat;
  }

在类的析构函数中,我们清理数据,并删除对象。

CNeuronConcatenate::~CNeuronConcatenate()
  {
   if(!!ConcWeights)
      delete ConcWeights;
   if(!!ConcDeltaWeights)
      delete ConcDeltaWeights;
   if(!!ConcFirstMomentum)
      delete ConcFirstMomentum;
   if(!!ConcSecondMomentum)
      delete ConcSecondMomentum;
  }

在 Init 对象初始化方法中,安排指定所有必要数据缓冲区的大小。 该方法在参数中接收必要的初始数据:

  • numOutputs — 下一层的神经元数量
  • open_cl — 指向 OpenCL 关联环境的对象句柄指针
  • numNeurons — 当前层中的神经元数量
  • numInputs1 — 上一层中的元素数量
  • numInputs2 — 附加源数据缓冲区中的元素数量
  • optimization_type — 参数优化方法 ID。
bool CNeuronConcatenate::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint numNeurons, 
                              uint numInputs1, uint numInputs2, ENUM_OPTIMIZATION optimization_type, uint batch)
  {
   if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, numNeurons, optimization_type, batch))
      return false;

在方法的主体中,取代控制模块,我们调用父类的类似方法,并检查操作结果。 父类已经实现了基本控制,故我们不需要重复它们。 此外,父类的方法实现了所有继承对象和变量的初始化。 因此,我们仅需在这个方法的主体中安排初始化添加对象的过程。

首先,我们将创建并初始化一个具有随机值的权重系数矩阵,以便安排与前一个神经层的数据交换。 请注意,应设置足够的权重矩阵大小,以便安排与前一层和附加源数据缓冲区的操作。 这正是我们在创建前向验算内核时所设想的方式。 如今我们在主程序端创建类方法时,依然要坚持如此。

   i_SecondInputs = (int)numInputs2;
   if(!ConcWeights)
     {
      ConcWeights = new CBufferFloat();
      if(!ConcWeights)
         return false;
     }
   int count = (int)((numInputs1 + numInputs2 + 1) * numNeurons);
   if(!ConcWeights.Reserve(count))
      return false;
   float k = (float)(1.0 / sqrt(numNeurons + 1.0));
   for(int i = 0; i < count; i++)
     {
      if(!ConcWeights.Add((2 * GenerateWeight()*k - k)*WeightsMultiplier))
         return false;
     }
   if(!ConcWeights.BufferCreate(OpenCL))
      return false;

接下来,取决于参数中指定的权重系数更新方法,我们初始化动量缓冲区。 您或许还记得,我们为 SGD 准备了一个动量缓冲区。 如果调用 Adam 方法,将初始化 2 个动缓冲区。 我们删除未用到的对象,这将令我们能够更有效地使用可用资源。

   if(optimization == SGD)
     {
      if(!ConcDeltaWeights)
        {
         ConcDeltaWeights = new CBufferFloat();
         if(!ConcDeltaWeights)
            return false;
        }
      if(!ConcDeltaWeights.BufferInit(count, 0))
         return false;
      if(!ConcDeltaWeights.BufferCreate(OpenCL))
         return false;
      if(!!ConcFirstMomentum)
         delete ConcFirstMomentum;
      if(!!ConcSecondMomentum)
         delete ConcSecondMomentum;
     }
   else
     {
      if(!!ConcDeltaWeights)
         delete ConcDeltaWeights;
      //---
      if(!ConcFirstMomentum)
        {
         ConcFirstMomentum = new CBufferFloat();
         if(CheckPointer(ConcFirstMomentum) == POINTER_INVALID)
            return false;
        }
      if(!ConcFirstMomentum.BufferInit(count, 0))
         return false;
      if(!ConcFirstMomentum.BufferCreate(OpenCL))
         return false;
      //---
      if(!ConcSecondMomentum)
        {
         ConcSecondMomentum = new CBufferFloat();
         if(!ConcSecondMomentum)
            return false;
        }
      if(!ConcSecondMomentum.BufferInit(count, 0))
         return false;
      if(!ConcSecondMomentum.BufferCreate(OpenCL))
         return false;
     }
//---
   return true;
  }

我们完成了类初始化方法的操作,并转入组织主要功能。 首先,我们将创建 feedForward 验算方法。 不同于所有之前所研究类中的直接传递方法,此方法在其参数中接收 2 个指向对象的指针:前一个神经层,和一个额外的源数据缓冲区。 这没啥奇怪的,因为这正是所创建类的主要区别特征。 但这种方式需要在所创建类之外的主程序端进行额外的工作。 我们稍后会讨论这个问题。

bool CNeuronConcatenate::feedForward(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput)
  {
   if(!OpenCL || !NeuronOCL || !SecondInput)
      return false;

在方法的主体中,我们首先检查所接收指针的相关性。 此外,我们将检查指向对象的指针是否存在,如此才可操控 OpenCL 关联环境。 如果有一个指针缺失,我们都将以否定结果终止该方法。

接下来,我们检查附加数据缓冲区的大小。 它应该包含足够数量的元素。 请注意,我们可以指定更大的缓冲区大小。 但在操作过程中,在初始化类时,只会按第一个元素指定的数量初始化缓冲区。

   if(SecondInput.Total() < i_SecondInputs)
      return false;
   if(SecondInput.GetIndex() < 0 && !SecondInput.BufferCreate(OpenCL))
      return false;

然后,我们在 OpenCL 关联环境中检查指向数据缓冲区的指针,并在必要时创建一个新的缓冲区。

注意,仅当关联环境中没有指向数据缓冲区的指针时,我们才会创建新的缓冲区。 如果存在,我们就不必将数据重新加载到关联环境中。 我们相信,指针的存在表明关联环境中存在数据。 因此,当缓冲区的内容在主程序一侧发生变化时,需要将数据复制到关联环境当中。 用户有责任确保关联环境内存中的数据是最新的。

接下来,我们将指针传递给数据缓冲区,并将必要的常量传递给内核参数。 此过程对于所有内核都是雷同的。 只有内核、参数和指向相应数据缓冲区的指针的标识符会更改。 所有数学运算都应在自身 OpenCL 程序端的内核主体中指定。

   if(!OpenCL.SetArgumentBuffer(def_k_ConcatFeedForward, def_k_cff_matrix_w, ConcWeights.GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_ConcatFeedForward, def_k_cff_matrix_i1, NeuronOCL.getOutputIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_ConcatFeedForward, def_k_cff_matrix_i2, SecondInput.GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_ConcatFeedForward, def_k_cff_matrix_o, Output.GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_ConcatFeedForward, def_k_cff_inputs1, (int)NeuronOCL.Neurons()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_ConcatFeedForward, def_k_cff_inputs2, (int)i_SecondInputs))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_ConcatFeedForward, def_k_cff_activation, (int)activation))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }

在方法操作结束时,我们指定运行内核的任务空间,并将其放入执行队列当中。

   uint global_work_offset[1] = {0};
   uint global_work_size[1];
   global_work_size[0] = Output.Total();
   if(!OpenCL.Execute(def_k_ConcatFeedForward, 1, global_work_offset, global_work_size))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
//---
   return true;
  }

于此,重要的是控制每个阶段所调用内核的规格正确性,以及缓冲区 ID,及其内容。 当然,我们不应该忘记控制每一步操作的正确性。

分发误差梯度和更新权重矩阵的方法基于类似的算法,您可以在附件中掌握它们。 应当注意的是,在分发误差梯度时,会在附加的源数据层次添加误差梯度缓冲区。 在这项操作中,我们不会下载和用到其数据。 但是,如果第二个模型生成了其它初始数据的向量,则将来也许需要它。

创建 CNeuronConcatenate 类的方法之后,我们应当仔细安排一个过程,把用户源数据的额外缓冲区从主程序传输到特定神经层。 通常,该过程的组织按这般方式,即在创建模型后,用户仅用 2 种方法操作:整个模型的正向和逆向验算。 用户不控制神经层之间的数据传输。 整个过程在我们函数库的“幕后”发生。 因此,用户应该能够调用一个前向验算方法,并在其参数中指定 2 个数据缓冲区。 之后,模型应独立地将数据分发到相应的信息流之中。

在此阶段,我们计划仅用一个附加数据层。 为了跟踪哪个神经层传输附加源数据的额外过程不至于复杂化,故决定将指向缓冲区的指针传递给所有神经层。 如何使用它则在由类自身的层面做决定。

我们不会详细研究如何顺链条在若干个方法中添加一个参数。 附件中提供了所有方法和函数的完整代码。 我们只讨论一个细节:尽管所有类的直接传递方法都具有雷同的名称,且被声明为虚拟方法,但在某些类中会加入参数,而在其它类中则没有参数,这样在继承类中就不允许重新定义全部方法。 为了保持遗传性,在以前类中创建的所有正向和后向验算方法我们都必须返工。 我们并没有这样做。 取而代之,我们只是在底层神经层的调度方法中添加了额外的控制。 我们来看看直接验算方法的例子。

在 CNeuronBaseOCL::FeedForward 派发方法的参数中,我们添加一个指向数据缓冲区的指针,并为其分配默认值。 这个技巧允许我们仍然调用该方法,只需一个指向前一个神经层的指针。 当用到由函数库以前创建的模型时,这很实用,并能在不进行任何修改的情况下编译以前创建的程序。

接下来,我们检查当前神经层的类型。 如果我们在一个类中合并来自两个线程的数据,那么我们调用相应的前向验算方法。 否则,我们使用以前创建的算法。 下面只是修改部分的方法代码。 方法代码没有更深层的修改。 CNeuronBaseOCL::FeedForward 方法的完整代码亦可在附件中找到。 在那里,您还可以找到修改后的反向验算派发方法。 还向它们加入了其它缓冲区,默认指针为 null。

bool CNeuronBaseOCL::FeedForward(CObject *SourceObject, CBufferFloat *SecondInput = NULL)
  {
   if(CheckPointer(SourceObject) == POINTER_INVALID)
      return false;
//---
   CNeuronBaseOCL *temp = NULL;
   if(Type() == defNeuronConcatenate)
     {
      temp = SourceObject;
      CNeuronConcatenate *concat = GetPointer(this);
      return concat.feedForward(temp, SecondInput);
     }

信息量很大,但文章篇幅有限。 因此,我只是简要通览一遍新的 CNeuronConcatenate 类方法。 我希望这不会对理解思路和方式产生负面影响。 无论如何,它们的算法与前面讨论过的相似类方法没有太大区别。 附件中给出了所有方法和类的完整代码。 如果您有任何问题,我随时准备在论坛和网站上的个人信息中回答。 选择任何您方便的沟通渠道。

我们更贴近深思 GCRL 强化学习方法,并研究构建和训练模型的过程。 如前,我们将创建 3 个 EA:

  • 示例的主要集合 “GCRL\Research.mq5”
  • 代理者训练 “GCRL\StudyActor.mq5”
  • 测试模型操作 “GCRL\Test.mq5”

我们将在 GCRL\Trajectory.mqh 包含文件中指示模型架构。

如上所述,我们将在一个代理者中汇集整个模型。 因而,我们仅有一个模型的架构描述。 在 CreateDescriptions 方法的主体中,我们将首先检查指向动态数组对象指针的相关性,并在必要时创建一个新对象。 在向所描述神经层添加新对象之前,请务必清除动态数组。

bool CreateDescriptions(CArrayObj *actor)
  {
//---
   CLayerDescription *descr;
//---
   if(!actor)
     {
      actor = new CArrayObj();
      if(!actor)
         return false;
     }
//--- Actor
   actor.Clear();

如常,我们首先创建源数据层。 它后面是常规化层。 我们已在上面提到过,编码器的初始数据只是历史数据和指标参数。 这反映在这些神经层的大小上。

//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   int prev_count = descr.count = (HistoryBars * BarDescr);
   descr.window = 0;
   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 = 1000;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

接下来,我们完全重复出自上一篇文章中的编码器架构。 它由一个卷积模组成。 它后随 3 个完全连接层,并以变分自动编码器的隐含表示的编码器层结束。 对于一个完整的模型来说,这是一个略微不寻常的方案。 我们已经谈论过有关划分算法和模型的约定。 我们来看看实际结果。

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   prev_count = descr.count = prev_count - 1;
   descr.window = 2;
   descr.step = 1;
   descr.window_out = 4;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronProofOCL;
   prev_count = descr.count = prev_count;
   descr.window = 4;
   descr.step = 4;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   prev_count = descr.count = prev_count - 1;
   descr.window = 2;
   descr.step = 1;
   descr.window_out = 4;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 5
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronProofOCL;
   prev_count = descr.count = prev_count;
   descr.window = 4;
   descr.step = 4;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 6
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 256;
   descr.optimization = ADAM;
   descr.activation = TANH;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 7
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 128;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 8
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 2 * NSkills;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 9
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronVAEOCL;
   descr.count = NSkills;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

编码器描述已完成。 我们转入创建我们的代理者。 其架构从合并 2 个数据流的层开始。 第一个流等于编码器结果的大小。 第二个等于描述任务的向量大小。 我们将采用平衡状态的描述作为描述手头任务的向量。

在理论部分,我们谈论过子任务可分离性的必要性。 在我们的简化设想中,我们将仅用到 2 个子任务:

  • 搜索开仓入场点
  • 搜索持仓离场点

我们在账户状态描述的结构中已指示了持仓。 因此,如果持仓的交易量为 “0”,则任务是开仓。 否则,我们正在寻找一个离场点。 这个思路很简单,勾起使用一个独热向量的回忆。 唯一的区别是持仓的交易量。 它很少等于 “1”,因为我们采用最小手数,并允许多笔并发开仓。

//--- layer 10
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConcatenate;
   descr.count = 256;
   descr.window=prev_count;
   descr.step=AccountDescr;
   descr.optimization = ADAM;
   descr.activation = TANH;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

在描述帐户的状态时,我们采用相对单位。 我们预计它们的值将接近归一化数据。 因此,我们在此不会用到批量常规化层。

接下来是 2 个全连接层的决策模块,和完全参数化的 FQF 分位数函数模块。 正如您所见,我们在上一篇文章中的代理者使用了类似的决策模块。 在那里,我们已讨论过每个神经层方案的主要属性和特征。

//--- layer 11
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 256;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 12
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 256;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 13
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronFQF;
   descr.count = NActions;
   descr.window_out = 32;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//---
   return true;
  }

在描述了模型的架构之后,我们转到创建一个机器人 “GCRL\Research.mq5”,收集样本的主要数据库。 从一篇文章转移到另一篇文章,而该 EA 的算法几乎没有变化。 我把其细节的研究放到本文的范畴之外。 完整的 EA 代码可在附件中找到。 我们只简要讨论使用 GCRL 方法引起的变化。

首先,我们要记住,最新模型的缺点之一是持仓长期保留。 我们可以注意到,描述账户状态的向量包含每个方向的持仓交易量和累计盈利。 但没有指出开仓的时间。 如果我们想训练一个代理者来控制这个过程,那么我们应该为它提供一个相应的参考点。

在我们的代理者动作范围内,只有全部平仓选项。 因此,我没看到将多头和空头的开仓时间分开的必要。 我们引入一个所有仓位的公用参数。 同时,我们想过创建一个参数,不仅能取决于时间,还基于持仓量、累计盈亏。

作为这样的指标,我们建议采用持仓盈亏的绝对值与持续时间求权重,并累计求和。 这就令我们能够根据开仓时间、交易量、和市场波动(按盈亏间接)调整指标。 采用盈利的绝对值令我们能够消除有盈利和无盈利持仓的相互抵消影响。 

 考虑到上述情况,我们将调整描述账户状态的过程,该过程在 EA 的 OnTick 方法中执行。

我们会将账户余额和净值指标存储在账户状态描述的前 2 个元素之中。 为了减少信息量,并提高其品质,我们放弃了保证金指标的指示,因为它们在当前任务的上下文中信息内容作用较低。 不过,我不排除在后续操作中把它们加上的可能。

开仓时间考虑以秒为单位,我们采用 H1 时间帧。 我们来立即检测以小时为单位调整仓位有效时间的乘数。 在此,我们将添加一个变量,并按上述等式计算持仓的惩罚。 不过,我们不希望持仓罚款超过其营收。 为此目的,我们将每小时累计利润的 1/10 作为罚款。 在上面的等式中采用利润的绝对值,这样无论持仓盈亏我们都能加以惩罚。

我们将当前时间保存到一个局部变量之中,并开始循环搜索持仓。 在循环体中,我们计算持仓的交易量,和每个方向的累计盈亏,以及持仓的罚款总计。

   sState.account[0] = (float)AccountInfoDouble(ACCOUNT_BALANCE);
   sState.account[1] = (float)AccountInfoDouble(ACCOUNT_EQUITY);
//---
   double buy_value = 0, sell_value = 0, buy_profit = 0, sell_profit = 0;
   double position_discount = 0;
   double multiplyer = 1.0 / (60.0 * 60.0 * 10.0);
   int total = PositionsTotal();
   datetime current = TimeCurrent();
   for(int i = 0; i < total; i++)
     {
      if(PositionGetSymbol(i) != Symb.Name())
         continue;
      switch((int)PositionGetInteger(POSITION_TYPE))
        {
         case POSITION_TYPE_BUY:
            buy_value += PositionGetDouble(POSITION_VOLUME);
            buy_profit += PositionGetDouble(POSITION_PROFIT);
            break;
         case POSITION_TYPE_SELL:
            sell_value += PositionGetDouble(POSITION_VOLUME);
            sell_profit += PositionGetDouble(POSITION_PROFIT);
            break;
        }
      position_discount -= (current - PositionGetInteger(POSITION_TIME)) * multiplyer*MathAbs(PositionGetDouble(POSITION_PROFIT));
     }
   sState.account[2] = (float)buy_value;
   sState.account[3] = (float)sell_value;
   sState.account[4] = (float)buy_profit;
   sState.account[5] = (float)sell_profit;
   sState.account[6] = (float)position_discount;

循环迭代完成后,我们将结果值保存到相应的数组元素当中,以便写入样本数据库。

在将数据传递到我们的模型之前,我们会将其转换为相对单位字段。

   State.AssignArray(sState.state);
   Account.Clear();
   float PrevBalance = (Base.Total <= 0 ? sState.account[0] : Base.States[Base.Total - 1].account[0]);
   float PrevEquity = (Base.Total <= 0 ? sState.account[1] : Base.States[Base.Total - 1].account[1]);
   Account.Add((sState.account[0] - PrevBalance) / PrevBalance);
   Account.Add(sState.account[1] / PrevBalance);
   Account.Add((sState.account[1] - PrevEquity) / PrevEquity);
   Account.Add(sState.account[2]);
   Account.Add(sState.account[3]);
   Account.Add(sState.account[4] / PrevBalance);
   Account.Add(sState.account[5] / PrevBalance);
   Account.Add(sState.account[6] / PrevBalance);

我要提醒您,在描述直接传递方法时,我们强调用户要负责 OpenCL 关联环境内存中附加源数据缓冲区的数据相关性。 因此,在更新帐户信息缓冲区后,我们会将其内容传输到关联环境内存之中。 仅在其后,我们才会调用代理者的直接传递方法,传递指向两个数据缓冲区的指针。

   if(Account.GetIndex()>=0)
      if(!Account.BufferWrite())
         return;
   if(!Actor.feedForward(GetPointer(State), 1, false, GetPointer(Account)))
      return;

取样和执行代理者动作的模块移植自类似的 EA,没有更改,故我们将在此省略其讲述。

在讲完收集样本的 EA 中 OnTick 函数修改的最后,有必要对奖励函数说几句话。 如前,我们的奖励函基础是账户余额变化的相对值。 但 GCRL 方法为达成局部目标提供了额外奖励。 在我们的例子中,我们将使用惩罚。 对于平仓任务,我们每次都会减去上述计算出的累计盈亏绝对值加权总和的指标。 通过这样做,我们尽可能地惩罚累积重大利润或亏损的持仓。 这应该会鼓励代理者平仓。 同时,累积利润较小的仓位则不会产生大额的罚款。 这令代理者能够期待盈利累积。

   float reward = Account[0];
   if((buy_value+sell_value)>0)
     reward+=(float)position_discount;
   else
     reward-=atr;
   if(!Base.Add(sState, act, reward))
      ExpertRemove();
//---
  }

如果没有持仓,我们将鼓励代理者进行交易。 在这种情况下,罚款由 ATR 指标的当前值为额度。

否则,EA 的算法没有发生任何变化。 您可以在附件中找到其完整代码。

在完成收集样本数据库 EA “GCRL\Research.mq5” 的工作后,我们在策略测试器的慢速优化模式下启动它。 我们转入 “GCRL\StudyActor.mq5” 代理者训练 EA。

在这项工作中,我们仅根据存储在样本数据库中的动作和奖励来训练代理者。 我们不会像在上一篇文章中那样计算其它动作的预测性奖励。 取而代之,我们将专注于教导代理者根据手头任务构建政策。 我们将利用该事实的好处,即我们的样本数据库包含一个历史时间段的验算。 但由于在收集样本数据库的阶段有大量随机选择的动作,故在一个历史时刻的每次验算之中,我们会收到一组不同的持仓、由代理者不同动作得到的累积盈亏、及随后的奖励。 这意味着我们可以从一个历史时刻,配合代理者的各种本地任务设置,运作若干次模型的向前和向后验算。 这将给予我们多次回放某个时刻、并探索环境的效果。

我们不会浪费资源和时间去寻找雷同的历史状态。 我们简单地利用历史数据的平稳性优点。 毕竟,很容易注意到我们所有的测试代理者都从一个历史时刻开始,并“行经”相同数量的步骤(蜡烛)。 例外情况也许是因爆仓而停止测试。 但是,所有验算中的每个 N 步始终对应一个历史时刻。 这就是我们将构建代理者训练的基础。

如常,模型训练在 “GCRL\StudyActor.mq5” EA 的 Train 函数中执行。 在函数的开头,我们依据传递的样本数据库来量化它。 然后我们组织第一个循环,在其中我们寻找步数最大的验算。 我们不保存特定的验算,而只保存步数。 我们为训练做准备时,将用到它针对特定历史时刻取样。

void Train(void)
  {
   int total_tr = ArraySize(Buffer);
   int total_steps = 0;
   for(int tr = 0; tr < total_tr; tr++)
     {
      if(Buffer[tr].Total > total_steps)
         total_steps = Buffer[tr].Total;
     }

接下来,我们将安排一个由 2 个嵌套循环组成的系统。 第一个基于模型训练的迭代次数。 在此循环的主体中,为了训练迭代,我们会在一个历史时刻取样。 在嵌套循环中,我们将遍历所有可用的验算,并检查其中是否存在已取样状态。

   uint ticks = GetTickCount();
//---
   for(int iter = 0; (iter < Iterations && !IsStopped()); iter ++)
     {
      int i = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * (total_steps - 2));
      for(int tr = 0; tr < total_tr; tr++)
        {
         if(i >= (Buffer[tr].Total - 1))
            continue;

如果存在该条件,我们将使用存储的数据训练代理者,然后转入下一个验算。

         State.AssignArray(Buffer[tr].States[i].state);
         float PrevBalance = Buffer[tr].States[MathMax(i - 1, 0)].account[0];
         float PrevEquity = Buffer[tr].States[MathMax(i - 1, 0)].account[1];
         Account.Clear();
         Account.Add((Buffer[tr].States[i].account[0] - PrevBalance) / PrevBalance);
         Account.Add(Buffer[tr].States[i].account[1] / PrevBalance);
         Account.Add((Buffer[tr].States[i].account[1] - PrevEquity) / PrevEquity);
         Account.Add(Buffer[tr].States[i].account[2]);
         Account.Add(Buffer[tr].States[i].account[3]);
         Account.Add(Buffer[tr].States[i].account[4] / PrevBalance);
         Account.Add(Buffer[tr].States[i].account[5] / PrevBalance);
         Account.Add(Buffer[tr].States[i].account[6] / PrevBalance);
         //---
         if(Account.GetIndex()>=0)
            Account.BufferWrite();
         if(!Actor.feedForward(GetPointer(State), 1, false,GetPointer(Account)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            ExpertRemove();
            break;
           }
         //---
      ActorResult = vector<float>::Zeros(NActions);
      ActorResult[Buffer[tr].Actions[i]] = Buffer[tr].Revards[i];
      Result.AssignArray(ActorResult);
      if(!Actor.backProp(Result, 0, NULL, 1, false,GetPointer(Account),GetPointer(Gradient)))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         ExpertRemove();
         break;
        }
         if(GetTickCount() - ticks > 500)
           {
            string str = StringFormat("%-15s %5.2f%% -> Error %15.8f\n", "Actor", 
                                       iter * 100.0 / (double)(Iterations),
                                       Actor.getRecentAverageError());
            Comment(str);
            ticks = GetTickCount();
           }
        }
     }

因此,我们的代理者将根据验算次数,并配以不同的本地子任务公式,回放每个单独的状态。 因此,我们想要展示代理者,其动作不仅应考虑环境状态,还应考虑本地子任务。 如您所记,在收集样本数据库时,我们添加了对每个步骤未能完成本地任务的惩罚。 现在,在每此验算中,我们将为一个历史时刻提供不同的奖励,这将与烟笋的本地子任务相对应。

智能交易系统代码的其余部分保持不变。 文章中使用的所有程序均可在附件中找到的完整代码。


3. 测试

在完成 EA 工作后,我们转入训练模型,并测试获得的结果。 我们不会更改模型训练参数。 如前,该模型是基于 EURUSD H1 历史数据上训练的。 指标采用默认参数。 我们的代理者基于 2023 年的 4 个月数据进行了训练。 我们在 2023 年 6 月 1 日至 18 日期间检验了训练质量,以及代理者处理新数据的能力。

测试结果显示在下面的屏幕截图中。 如您所见,我们设法在测试模型时获利。 在余额图表上,有增长阶段,也有平坦的走势。 我很高兴没有栽跟头。 一般来说,在 12 个交易日内,盈利因子为 2.2,恢复因子为 1.47。 EA 进行了 220 笔交易。 其中超过 53% 以盈利了结。 此外,平均盈利仓位几乎是平均无盈利仓位的 2 倍。 不幸的是,EA 只开立多头仓位。 我们已经遇到过类似的影响。 所应用的方式并未解决这个问题。

测试图形

测试结果

持仓时间

使用 GCRL 方法的积极方面包括减少持仓所需的时间。 在测试期间,最长持仓时间为 21 小时 15 分钟。 持仓的平均时间为 5 小时 49 分钟。 如您所记,对于未能完成平仓的任务,我们设定了每小时持仓累计盈利 1/10 的罚款。 换句话说,在持有10个小时后,罚款超过了该笔持仓的营收。


结束语

在本文中,我们引入了条件导向目标强化学习(GCRL)方法。 这种方法的一个特点是引入了本地子任务,和对其成就的奖励。 这令我们能够将一项全局任务划分成几个较小的任务,并逐步达成。

这种方式有诸多优点。 它通过将任务分解为更小、更易于管理的组件来降低学习复杂性。 这简化了决策过程,并提高了代理者的训练速度。

此外,GCRL 有助于提高代理者的普适能力。 若代理者学会了解决不同的本地子任务,它能发展出一套可应用在不同关联环境的技能和策略。

最后,GCRL 在为代理者定义目标和标定物方面提供了灵活性。 我们可以根据自己的需求和环境条件选择和更改本地子任务。 这令代理者能够适应不同的情况,并有效地利用他们的技能来达成他们的目标。

我们利用 MQL5 实现了所提出的方法。 我们还训练了模型,并在训练集外的数据上检验了训练结果。 测试结果显示,仍有未解决的问题。 特别是,EA 仅在一个方向上开仓。 同时,这并没有妨碍它在测试期间获利。

还应该注意的是,持仓时间有所降低。 这确认了代理者的操作解决 2 个本地任务:开仓和平仓。

平心而论,测试结果是积极的,并允许使用该方法寻找新的解决方案。


参考文献列表

  • 变分授权作为基于目标的强化学习的表征学习
  • 神经网络变得轻松(第四十三部分):无需奖励函数精通技能
  • 神经网络变得轻松(第四十四部分):动态学习技能
  • 神经网络变得轻松(第四十五部分):训练状态探索技能

  • 本文中用到的程序

    # 名称 类型 说明
    1 Research.mq5 智能交易系统 样本收集 EA
    StudyActor.mq5  智能交易系统 代理者训练 EA
    3 Test.mq5 智能交易系统 模型测试 EA
    4 Trajectory.mqh 类库 系统状态定义结构
    5 FQF.mqh 类库 完全参数化模型的工作安排类库
    6 NeuroNet.mqh 类库 用于创建神经网络的类库
    7 NeuroNet.cl 代码库 OpenCL 程序代码库
    8 VAE.mqh
    类库
    变分自动编码器潜伏层类库

    本文由MetaQuotes Ltd译自俄文
    原文地址: https://www.mql5.com/ru/articles/12816

    附加的文件 |
    MQL5.zip (615.73 KB)
    Heiken-Ashi指标与移动平均指标组合能够提供好的信号吗? Heiken-Ashi指标与移动平均指标组合能够提供好的信号吗?
    策略的组合可能会提供更好的机会,我们可以把指标和形态一起使用,或者更进一步,多个指标和形态一起,这样我们可以获得额外的确认因子。移动平均帮我们确认和驾驭趋势,它们是最为人所知的技术指标,这是因为它们的简单性和为分析增加价值的良好记录。
    在 MQL5 中利用 ARIMA 模型进行预测 在 MQL5 中利用 ARIMA 模型进行预测
    在本文中,我们继续开发构建 ARIMA 模型的 CArima 类,添加支持预测的直观方法。
    简单均值回归交易策略 简单均值回归交易策略
    均值回归是一种逆势交易,交易者预估价格将返回到某种形式的均衡点位,通常依据均值或其它向心趋势统计值来衡量。
    如何在 MQL5.com 上造就成功的信号提供者 如何在 MQL5.com 上造就成功的信号提供者
    在本文中我的主要目标是为您提供一个简单而精准的步骤说明,助您变身 MQL5.com 上的顶级信号提供者。 借鉴我的知识和经验,我将讲解如何造就一名成功的信号提供者,包括如何寻找、测试、和优化一个优秀的策略。 此外,我将提供有关发布信号、撰写令人信服的推介、以及有效推广和管理信号的提示。