English Русский Español Deutsch 日本語 Português
preview
交易中的神经网络:搭配预测编码的混合交易框架(终篇)

交易中的神经网络:搭配预测编码的混合交易框架(终篇)

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

概述

在上一篇文章中,我们详细研习了混合交易系统 Stockformer 的理论层面,其结合了预测编码、和强化学习算法,来预测市场趋势和金融资产的动态。StockFormer 是一个混合框架,汇集了若干种关键技术和方式,以应对金融市场中的复杂挑战。其核心功能是使用三个修改后的变换器分支,每条分支负责捕捉市场动态的不同层面。第一条分支提取资产之间隐藏的相互依赖关系,而第二和第三条分支则侧重于短期和长期预测,令系统能够审计当前和未来的市场趋势。

这些分支的集成是经由一系列注意力机制达成的,其强化了模型从多头模块中学习的能力,改进了其对数据中潜在形态的处理和检测。如是结果,该系统不仅能够基于历史数据分析和预测趋势,还能参考多种资产之间的动态关系。这对于制定能够适应快速变化市场条件的交易策略尤其重要。

下面提供了 StockFormer 框架的原始可视化。

在上一篇文章的实践章节,我们实现了多样化多头注意力DMH-Attn)模块的算法,其是强化变换器模型中标准注意力机制的基础。DMH-Attn 显著提升了检测金融时间序列中不同形态和相互依赖性的效率,这在与嘈杂和高度不稳定的数据打交道时尤有价值。

在本文中,我们将继续这项工作,关注模型不同部分的架构,以及它们在创建统一状态空间中的相互作用机制。此外,我们还将研习决策制定智代交易政策的训练过程。



预测编码模型

我们从预测编码模型开始。StockFormer 框架的作者提议使用三种预测模型。其一设计用于识别描述所分析金融资产动态的数据中的依赖关系。另外两个经过训练,用来预测正在研究的多模态时间序列的即将到来的走势,每个时间序列都有不同的计划横向范围。

这三种模型均基于编码器-解码器变换器架构,利用了修改版 DMH-Attn 模块。在我们的实现中,编码器解码器将作为单独模型创建。


依赖项搜索模型


金融资产时间序列的依赖项搜索模型的架构在 CreateRelationDescriptions 方法中定义。

bool CreateRelationDescriptions(CArrayObj *&encoder, CArrayObj *&decoder)
  {
//---
   CLayerDescription *descr;
//---
   if(!encoder)
     {
      encoder = new CArrayObj();
      if(!encoder)
         return false;
     }
   if(!decoder)
     {
      decoder = new CArrayObj();
      if(!decoder)
         return false;
     }

该方法的参数包括两个指向动态数组的指针,我们必须将编码器和解码器的架构描述传至其中。在方法内,我们检查接收到的指针的有效性,并在必要时创建动态数组对象的新实例。

对于编码器的第一层,我们使用足够大小的全连接层来接受原始输入的所有张量数据。

回想一下,编码器要接收所分析历史深度的整个历史数据。

//--- Encoder
   encoder.Clear();
//---
   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;
     }

StockFormer 的作者建议在依赖项搜索模型的训练期间,随机掩码多达 50% 的输入数据。模型必须基于剩余信息重造掩码数据。在我们的编码器中,该掩码由 Dropout 层处理。

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

紧随其后,我们添加一个可学习的位置编码层。

//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronLearnabledPE;
   descr.count = prev_count;
   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 = defNeuronDMHAttention;
   descr.window = BarDescr;
   descr.window_out = 32;
   descr.count = HistoryBars;
   descr.step = 4;               //Heads
   descr.layers = 3;             //Layers
   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 = defNeuronCrossDMHAttention;
//--- Windows
     {
      int temp[] = {BarDescr, BarDescr};
      if(ArrayCopy(descr.windows, temp) < (int)temp.Size())
         return false;
     }
   descr.window_out = 32;
//--- Units
     {
      int temp[] = {prev_count/descr.windows[0], HistoryBars};
      if(ArrayCopy(descr.units, temp) < (int)temp.Size())
         return false;
     }
   descr.step = 4;               //Heads
   descr.layers = 3;             //Layers
   descr.batch = 1e4;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!decoder.Add(descr))
     {
      delete descr;
      return false;
     }

由于解码器的输出将与原始输入数据进行比较,故我们使用逆向归一化层来终定模型。

//--- layer 5
   prev_count = descr.units[0] * descr.windows[0];
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronRevInDenormOCL;
   descr.count = prev_count;
   descr.layers = 1;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!decoder.Add(descr))
     {
      delete descr;
      return false;
     }
//---
   return true;
  }


预测模型


这两个预测模型,尽管具有不同的计划横向范围,但共享相同的架构,其在 CreatePredictionDescriptions 方法中定义。值得注意的是,编码器设计用于接收的多模态时间序列,与先前依赖项搜索模型分析时相同。因此,我们完全复用了编码器架构,但 Dropout 层除外,因为在预测模型训练期间未应用输入掩码。

预测模型的解码器仅接收最后一根柱线的特征向量作为输入,其值经由全连接层传递。

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

在本文中,我们专注于训练模型,从而分析单一金融工具的历史数据。鉴于此,输入数据中仅有单根柱线描述向量,会令位置编码的有效性最小化。出于该原因,我们在此忽略它。不过,在分析多种金融工具时,建议在输入数据中加上位置编码。

接着来到三层多样化多头交叉注意力模块,其用相应编码器的输出作为第二信息源。

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronCrossDMHAttention;
//--- Windows
     {
      int temp[] = {BarDescr, BarDescr};
      if(ArrayCopy(descr.windows, temp) < (int)temp.Size())
         return false;
     }
   descr.window_out = 32;
//--- Units
     {
      int temp[] = {1, HistoryBars};
      if(ArrayCopy(descr.units, temp) < (int)temp.Size())
         return false;
     }
   descr.step = 4;               //Heads
   descr.layers = 3;             //Layers
   descr.batch = 1e4;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!decoder.Add(descr))
     {
      delete descr;
      return false;
     }

在模型的输出中,我们添加一个不含激活函数的全连接投影层。

//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = BarDescr;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!decoder.Add(descr))
     {
      delete descr;
      return false;
     }
//---
   return true;
  }

此处应当强调两个要点。首先,不同于预测所分析时间序列延续预期值的传统模型,StockFormer 框架的作者提出了预测指标的变化系数。这意味着输出向量的大小与解码器的输入张量匹配,与规划横向范围无关。这样一种方式允许我们在解码器输出端剔除逆向归一化层。甚至,在该预测设置中,逆向归一化变得多余。由于变化系数和生料指标属于不同的分布。

其二,关于在解码器输出端使用全连接层。如早前所述,我们正在分析单一金融工具的多模态时间序列。因此,我们预期所有正在分析的单一序列都展现出不同程度的相关性。因此,它们的变化系数必须一致。因此,在这种情况下,全连接层是对应的。然而,如果您计划并行分析多种金融工具,则建议用卷积层替换全连接层,从而能各自预测每种资产的变化系数。

我们复查预测编码模型架构至此完毕。它们的完整设计描述可在附录中找到。


训练预测编码模型


StockFormer 框架中,预测编码模型的训练作为一个专门阶段实现。在复查了预测模型的架构之后,我们现在转向构造一个智能系统来训练它们。EA 的基本方法主要借鉴了本系列前几篇文章中讨论的类似程序。因此,在本文中,我们将主要关注于 Train 方法内组织的直接训练算法。

首先,我们将做一点准备工作。此处,我们形成了一个概率向量,用于从经验回放缓冲区中选择轨迹,为那些具有最大盈利能力的轨迹分配更高的概率。以这种方式,我们将训练过程偏向于可盈利的运行,并用积极的示例填充它。

void Train(void)
  {
//---
   vector<float> probability = GetProbTrajectories(Buffer, 0.9);
//---
   vector<float> result, target, state;
   matrix<float> predict;
   bool Stop = false;
//---
   uint ticks = GetTickCount();

在该阶段,我们还声明了必要局部变量,在训练期间存储中间数据。准备工作完成后,我们启动训练迭代循环。在 EA 的外部参数中定义迭代总数。

   for(int iter = 0; (iter < Iterations && !IsStopped() && !Stop); iter ++)
     {
      int tr = SampleTrajectory(probability);
      int i = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * (Buffer[tr].Total - 2 - NForecast));
      if(i <= 0)
        {
         iter --;
         continue;
        }
      if(!state.Assign(Buffer[tr].States[i].state) ||
         MathAbs(state).Sum() == 0 ||
         !bState.AssignArray(state))
        {
         iter --;
         continue;
        }
      if(!state.Assign(Buffer[tr].States[i + NForecast].state) ||
         !state.Resize((NForecast + 1)*BarDescr) ||
         MathAbs(state).Sum() == 0)
        {
         iter --;
         continue;
        }

在循环内,我们从经验回放缓冲区中采样一条轨迹、及其初始环境状态。然后,我们检查所选状态下是否存在历史数据,以及覆盖计划横向范围内的实际数据。如果这些检查都成功,我们将所需分析深度的历史值传送到相应的数据缓冲区之中,并执行所有预测模型的前向通验。

      //--- Feed Forward
      if(!RelateEncoder.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CBufferFloat*)NULL) ||
         !RelateDecoder.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CNet*)GetPointer(RelateEncoder)) ||
         !ShortEncoder.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CBufferFloat*)NULL) ||
         !ShortDecoder.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CNet*)GetPointer(ShortEncoder)) ||
         !LongEncoder.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CBufferFloat*)NULL) ||
         !LongDecoder.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CNet*)GetPointer(LongEncoder)))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         Stop = true;
         break;
        }

值得注意的是,尽管它们的架构相同,但每个预测模型都拥有自己的编码器。这增加了可训练模型的总数,相应增加了训练和操作的计算成本。不过,它还令每个模型能够捕获与其特定任务相关的依赖关系。

另一点涉及在解码器主流中使用生料输入张量。如早前所述,预测模型的解码器仅接受最后一根柱线作为输入。不过,在训练中,所有情况下都会使用跨整个分析深度的历史缓冲区。为了澄清,存储在回放缓冲区中的环境状态可表示为矩阵。在该矩阵中,行对应于柱线,列对应于特征(价格和指标)。第一行包含最后一根柱线的数据。因此,当传送大于解码器输入大小的张量时,模型简单地采取与输入层大小匹配的第一段即可。这正是我们所需要的,令我们能够避免创建额外的缓冲区、及不必要的数据副本。

前向通验成功之后,我们准备目标值,并执行反向传播。对于依赖项搜索模型,目标值是多模态时间序列本身。因此,我们能够立即经由解码器运行反向传播,将误差梯度传递至编码器。基于获得的梯度,我们相应地更新编码器参数。

      //--- Relation
      if(!RelateDecoder.backProp(GetPointer(bState), (CNet *)GetPointer(RelateEncoder)) ||
         !RelateEncoder.backPropGradient((CBufferFloat*)NULL))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         Stop = true;
         break;
        }

不过,对于预测模型,必须定义目标值。如早前所述,此处的目标是参数的变化系数。我们假设规划横向范围短于历史数据的分析深度。因此,为了计算目标值,我们从回放缓冲区中提取所需横向范围之前数个步骤记录的未来环境状态。然后我们将这个张量转换为矩阵,其中每一行对应一根柱线。

      //--- Prediction
      if(!predict.Resize(1, state.Size()) ||
         !predict.Row(state, 0) ||
         !predict.Reshape(NForecast + 1, BarDescr)
        )
        {
         iter --;
         continue;
        }

由于这样的矩阵第一行代表最后的柱线,故我们比计划横向范围多取一行。该截断矩阵的最后一行对应于当前正在分析的柱线。

重点要记住,回放缓冲区存储非归一化数据。为了令计算出的变化系数纳入一个有意义的范围,我们按未来值矩阵中每个参数的最大绝对值,对其进行归一化。如是结果,我们得到的系数通常位于 {-2.0, 2.0} 范围之内。

      result = MathAbs(predict).Max(0);

对于短期预测模型,目标是参数在下一根柱线处的变化系数。计算公式是预测矩阵最后两行之间的差值除以最大值向量,然后存储在相应的缓冲区之中。

      target = (predict.Row(NForecast - 1) - predict.Row(NForecast)) / result;
      if(!bShort.AssignArray(target))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         Stop = true;
         break;
        }

对于长期预测模型,我们将所有柱线的参数变化系数相加,并应用折扣因子。

      for(int i = 0; i < NForecast - 1; i++)
         target += (predict.Row(i) - predict.Row(i + 1)) / result * 
                              MathPow(DiscFactor, NForecast - i - 1);
      if(!bLong.AssignArray(target))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         Stop = true;
         break;
        }

一旦完整的目标值集合定义完毕,我们更新预测模型的参数,从而将预测误差降至最小。具体而言,我们首先经由短期预测模型的解码器和编码器进行反向传播,随后是长期模型。

      //--- Short prediction
      if(!ShortDecoder.backProp(GetPointer(bShort), (CNet *)GetPointer(ShortEncoder)) ||
         !ShortEncoder.backPropGradient((CBufferFloat*)NULL))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         Stop = true;
         break;
        }
      //--- Long prediction
      if(!LongDecoder.backProp(GetPointer(bLong), (CNet *)GetPointer(LongEncoder)) ||
         !LongEncoder.backPropGradient((CBufferFloat*)NULL))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         Stop = true;
         break;
        }

更新该阶段训练的所有模型之后,我们记录进度,以便通知用户,然后进行下一次训练迭代。

      //---
      if(GetTickCount() - ticks > 500)
        {
         double percent = double(iter) * 100.0 / (Iterations);
         string str = StringFormat("%-14s %6.2f%% -> Error %15.8f\n", "Relate", 
                                  percent, RelateDecoder.getRecentAverageError());
         str += StringFormat("%-14s %6.2f%% -> Error %15.8f\n", "Short", percent, 
                                            ShortDecoder.getRecentAverageError());
         str += StringFormat("%-14s %6.2f%% -> Error %15.8f\n", "Long", percent, 
                                             LongDecoder.getRecentAverageError());
         Comment(str);
         ticks = GetTickCount();
        }
     }

所有训练迭代完成之后,我们会清除图表上的注释字段(之前显示的训练更新)。

   Comment("");
//---
   PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Relate", RelateDecoder.getRecentAverageError());
   PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Short", ShortDecoder.getRecentAverageError());
   PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Long", LongDecoder.getRecentAverageError());
   ExpertRemove();
//---
  }

我们将结果打印在日志中,并启动 EA 操作的终结。

预测模型训练 EA 的完整源代码可在附件中找到(文件:“...\MQL5\Experts\StockFormer\Study1.mq5”)。

最后,应当注意的是,在本文的模型训练过程中,我们用到了与之前工作相同的输入数据结构。重点是,预测模型训练仅依赖独立于智代动作的环境状态。因此,用预先收集的数据集即可启动训练。我们现在转至下一阶段的工作。



政策训练

在训练预测模型的同时,我们转入下一阶段 — 训练智代行为政策。

模型架构


我们自准备该阶段所用的模型架构开始,如 CreateDescriptions 方法中定义。重点要注意,在 StockFormer 框架中,参与者评论者都将预测模型的输出作为输入,于其中经级联注意力模块合并为一个统一的子空间。在我们的函数库中,我们构建的模型能够搭配两个数据源。故此,我们把注意力级联切分至两个分离的模型。在第一个模型中,我们对齐来自两个规划横向范围的数据。作者建议使用来自主流的长期规划数据,因为它对噪声不太敏感。

对齐双条横向范围的模型架构很简单。在此,我们创建两个层:

  1. 完全连接输入层。
  2. 具有三个内部层的多样化交叉注意力模块。

//--- Long to Short predict
   long_short.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   int prev_count = descr.count = (BarDescr);
   descr.activation = None;
   descr.optimization = ADAM;
   if(!long_short.Add(descr))
     {
      delete descr;
      return false;
     }
//--- Layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronCrossDMHAttention;
//--- Windows
     {
      int temp[] = {BarDescr, BarDescr};
      if(ArrayCopy(descr.windows, temp) < (int)temp.Size())
         return false;
     }
   descr.window_out = 32;
//--- Units
     {
      int temp[] = {1, 1};
      if(ArrayCopy(descr.units, temp) < (int)temp.Size())
         return false;
     }
   descr.step = 4;               //Heads
   descr.layers = 3;             //Layers
   descr.batch = 1e4;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!long_short.Add(descr))
     {
      delete descr;
      return false;
     }

此处未用归一化层,因为模型输入是以前训练过的预测模型的输出,并非生料数据。

两个横向范围对齐的结果随后会扩充有关当前环境状态的信息,而取自依赖项搜索模型编码器的信息会被应用于输入数据。

回想一下,依赖项搜索模型经训练,可重造输入数据的掩码部分。在该阶段,我们期望每个单一时间序列都有一个基于其它单变量序列形成的预测状态表示。因此,编码器输出是环境状态的去噪张量,就如不符合模型预期的异常值会由其它序列衍生的统计值进行补偿。

该模型架构经环境状态信息扩充预测,与两个横向范围对齐模型密切相关。仅有的区别是我们修改了第二个数据源的序列长度。

//--- Predict to Relate
   predict_relate.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = (BarDescr);
   descr.activation = None;
   descr.optimization = ADAM;
   if(!predict_relate.Add(descr))
     {
      delete descr;
      return false;
     }
//--- Layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronCrossDMHAttention;
//--- Windows
     {
      int temp[] = {BarDescr, BarDescr};
      if(ArrayCopy(descr.windows, temp) < (int)temp.Size())
         return false;
     }
   descr.window_out = 32;
//--- Units
     {
      int temp[] = {1, HistoryBars};
      if(ArrayCopy(descr.units, temp) < (int)temp.Size())
         return false;
     }
   descr.step = 4;               //Heads
   descr.layers = 3;             //Layers
   descr.batch = 1e4;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!predict_relate.Add(descr))
     {
      delete descr;
      return false;
     }

将三个预测模型的输出组合成一个统一子空间,构造成注意力级联之后,我们继续构建参与者。参与者模型的输入是注意力级联的输出。

//--- Actor
   actor.Clear();
//--- Input Layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = (BarDescr);
   descr.activation = None;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

预测预期与帐户状态信息相结合。

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

这些组合信息经由一个决策制定模块传递,其实现作为具有随机输出头的 MLP

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = LatentCount;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   descr.probability = Rho;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 2 * NActions;
   descr.activation = None;
   descr.optimization = ADAM;
   descr.probability = Rho;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronVAEOCL;
   descr.count = NActions;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

在模型的输出中,每个方向的交易参数都调用搭配 sigmoid 激活函数的卷积层进行调整。

//--- layer 5
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   descr.count = NActions / 3;
   descr.window = 3;
   descr.step = 3;
   descr.window_out = 3;
   descr.activation = SIGMOID;
   descr.optimization = ADAM;
   descr.probability = Rho;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

评论者拥有类似架构,但它取代帐户状态,而是分析智代的动作。其的输出不用随机头。附录中提供了所有模型的完整架构。

政策训练程序


一旦模型架构定义完毕,我们规划训练算法。第二阶段涉及寻找最优的智代行为策略,从而最大化回报,同时最小化风险。

如前,训练方法从筹备开始。我们生成一个概率向量,基于性能从经验回放缓冲区中选择轨迹,并声明局部变量。

void Train(void)
  {
//---
   vector<float> probability = GetProbTrajectories(Buffer, 0.9);
//---
   vector<float> result, target, state;
   bool Stop = false;
//---
   uint ticks = GetTickCount();

然后我们进入训练循环,迭代次数由 EA 的外部参数设置。

   for(int iter = 0; (iter < Iterations && !IsStopped() && !Stop); iter ++)
     {
      int tr = SampleTrajectory(probability);
      int i = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * (Buffer[tr].Total - 2 - NForecast));
      if(i <= 0)
        {
         iter --;
         continue;
        }
      if(!state.Assign(Buffer[tr].States[i].state) ||
         MathAbs(state).Sum() == 0 ||
         !bState.AssignArray(state))
        {
         iter --;
         continue;
        }

在每次迭代内,我们都会对当前迭代的轨迹及其状态进行采样。确保验证我们是否拥有所有必要的数据。

不同于预测模型,政策训练需要额外的输入数据。提取环境状态描述之后,我们在相关时间步骤,从回放缓冲区收集账户余额和持仓。

      //--- Account
      bAccount.Clear();
      float PrevBalance = Buffer[tr].States[MathMax(i - 1, 0)].account[0];
      float PrevEquity = Buffer[tr].States[MathMax(i - 1, 0)].account[1];
      bAccount.Add((Buffer[tr].States[i].account[0] - PrevBalance) / PrevBalance);
      bAccount.Add(Buffer[tr].States[i].account[1] / PrevBalance);
      bAccount.Add((Buffer[tr].States[i].account[1] - PrevEquity) / PrevEquity);
      bAccount.Add(Buffer[tr].States[i].account[2]);
      bAccount.Add(Buffer[tr].States[i].account[3]);
      bAccount.Add(Buffer[tr].States[i].account[4] / PrevBalance);
      bAccount.Add(Buffer[tr].States[i].account[5] / PrevBalance);
      bAccount.Add(Buffer[tr].States[i].account[6] / PrevBalance);
      //---
      double time = (double)Buffer[tr].States[i].account[7];
      double x = time / (double)(D'2024.01.01' - D'2023.01.01');
      bAccount.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
      x = time / (double)PeriodSeconds(PERIOD_MN1);
      bAccount.Add((float)MathCos(x != 0 ? 2.0 * M_PI * x : 0));
      x = time / (double)PeriodSeconds(PERIOD_W1);
      bAccount.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
      x = time / (double)PeriodSeconds(PERIOD_D1);
      bAccount.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
      if(!!bAccount.GetOpenCL())
        {
         if(!bAccount.BufferWrite())
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }
        }

还为所分析状态添加了时间戳。

使用这些信息,我们经由预测编码模型和注意力级联执行前馈通验,以便将预测输出转换至统一的子空间。

      //--- Generate Latent state
      if(!RelateEncoder.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CBufferFloat*)NULL) ||
         !ShortEncoder.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CBufferFloat*)NULL) ||
         !ShortDecoder.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CNet*)GetPointer(ShortEncoder)) ||
         !LongEncoder.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CBufferFloat*)NULL) ||
         !LongDecoder.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CNet*)GetPointer(LongEncoder)) ||
         !LongShort.feedForward(GetPointer(LongDecoder), -1, GetPointer(ShortDecoder), -1) ||
         !PredictRelate.feedForward(GetPointer(LongShort), -1, GetPointer(RelateEncoder), -1)
        )
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         Stop = true;
         break;
        }

注意:在该阶段,不会执行依赖项搜索解码器,因其在政策训练或部署时没用。

接下来,我们优化评论者,从而把评估智代动作的误差最小化。从回放缓冲区提取所选状态的实际动作,并经由评论者通验。

      //--- Critic
      target.Assign(Buffer[tr].States[i].action);
      target.Clip(0, 1);
      bActions.AssignArray(target);
      if(!!bActions.GetOpenCL())
         if(!bActions.BufferWrite())
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }
      Critic.TrainMode(true);
      if(!Critic.feedForward(GetPointer(PredictRelate), -1, (CBufferFloat*)GetPointer(bActions)))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         Stop = true;
         break;
        }

所获动作的估值,如同评论者前馈通验的结果值,最初近似于随机分布。然而,经验回放缓冲区还存储了轨迹收集过程中由智代的实际行为获得的真实奖励。因此,我们能够训练评论者,把预测和实际奖励之间的误差最小化。

我们从经验回放缓冲区中提取实际奖励,并执行评论者的反向传播通验。

      result.Assign(Buffer[tr].States[i + 1].rewards);
      target.Assign(Buffer[tr].States[i + 2].rewards);
      result = result - target * DiscFactor;
      Result.AssignArray(result);
      if(!Critic.backProp(Result, (CBufferFloat *)GetPointer(bActions), (CBufferFloat *)GetPointer(bGradient)))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         Stop = true;
         break;
        }

接下来,我们进行参与者行为政策的实际训练。使用收集到的输入数据,我们执行参与者的前向通验,并根据当前政策生成动作张量。

      //--- Actor Policy
      if(!Actor.feedForward(GetPointer(PredictRelate), -1, (CBufferFloat*)GetPointer(bAccount)))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         Stop = true;
         break;
        }

紧接着,我们使用评论者评估生成的动作。

      Critic.TrainMode(false);
      if(!Critic.feedForward(GetPointer(PredictRelate), -1, (CNet*)GetPointer(Actor), -1))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         Stop = true;
         break;
        }

注意:在优化参与者的政策期间,评论者的训练模式被禁用。这允许误差梯度传播到参与者,而无需基于不相关的数据替代评论者的参数。

参与者政策训练发生在两个阶段。在第一阶段,我们评估经验回放缓冲区中记录的实际动作的有效性。如果奖励为正,我们把预测张量和实际动作张量之间的误差最小化。按受监督形式训练可盈利政策。

      if(result.Sum() >= 0)
         if(!Actor.backProp(GetPointer(bActions), (CBufferFloat*)GetPointer(bAccount), GetPointer(bGradient)) ||
            !PredictRelate.backPropGradient(GetPointer(RelateEncoder), -1, -1, false) ||
            !LongShort.backPropGradient(GetPointer(ShortDecoder), -1, -1, false) ||
            !ShortDecoder.backPropGradient((CNet *)GetPointer(ShortEncoder), -1, -1, false) ||
            !ShortEncoder.backPropGradient((CBufferFloat*)NULL) ||
            !LongDecoder.backPropGradient((CNet *)GetPointer(LongEncoder), -1, -1, false) ||
            !LongEncoder.backPropGradient((CBufferFloat*)NULL)
           )
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }

重点是,在该阶段,误差梯度向下传播到预测模型。这会对它们进行优调,以支持参与者的政策优化任务。

评论者引导阶段:我们通过传播来自评论者的误差梯度,,优化参与者的政策。该阶段调整政策时,无关实际动作在环境中的成果,仅依赖评论者对当前政策的评估。为此,我们将动作评估提高了 1%。

      Critic.getResults(Result);
      for(int c = 0; c < Result.Total(); c++)
        {
         float value = Result.At(c);
         if(value >= 0)
            Result.Update(c, value * 1.01f);
         else
            Result.Update(c, value * 0.99f);
        }

然后,我们将调整后的奖励传递给作为目标的评论者,并执行反向传播,将误差梯度传播至参与者。该操作在参与者的输出处产生误差梯度,引导动作朝向更高的盈利能力。

      if(!Critic.backProp(Result, (CNet *)GetPointer(Actor), LatentLayer) ||
         !Actor.backPropGradient((CBufferFloat*)GetPointer(bAccount), GetPointer(bGradient)) ||
         !PredictRelate.backPropGradient(GetPointer(RelateEncoder), -1, -1, false) ||
         !LongShort.backPropGradient(GetPointer(ShortDecoder), -1, -1, false) ||
         !ShortDecoder.backPropGradient((CNet *)GetPointer(ShortEncoder), -1, -1, false) ||
         !ShortEncoder.backPropGradient((CBufferFloat*)NULL) ||
         !LongDecoder.backPropGradient((CNet *)GetPointer(LongEncoder), -1, -1, false) ||
         !LongEncoder.backPropGradient((CBufferFloat*)NULL)
        )
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         Stop = true;
         break;
        }

生成的梯度经所有相关模型传播,类似于第一个训练阶段。

然后,我们向用户更新训练进度,并继续下一次迭代。

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

所有训练迭代完成后,我们会清除图表注释,将结果记录在日志当中,并启动程序终止,就如同第一个训练阶段。

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

应该注意的是,算法调整不仅影响模型训练智能系统,还影响环境交互 EA。然而,对环境交互算法的调整,在很大程度上镜像了上述参与者的前馈通验,并留待独立研究。因此,我们在此就不赘述这些算法的详细逻辑了。我鼓励您独立探索它们的实现。本文中用到的所有程序的完整源代码包含在附件当中。



测试

我们已完成了利用 MQL5 对 StockFormer 框架的扩展实现,并已到达我们工作的最后阶段 — 训练模型,并评估它们在真实历史数据上的性能。

如前所述,训练预测模型的初始阶段利用了早期研究时收集的数据集。该数据集包含 EURUSD 的 2023 全年 H1 时间帧历史数据。所有指标参数均按其默认值设置。

在预测模型训练期间,我们仅采用描述环境状态的历史数据,这独立于智代的行为。这令我们能够在不更新训练数据集合的情况下训练模型。训练过程一直持续到误差稳定在狭窄范围内。

第二个训练阶段 — 优化参与者的行为政策 — 这是迭代执行的,并周期性更新训练数据集合,从而反映当前政策。

我们利用 MetaTrader 5 策略测试器依据 2024 年 1 月的历史数据评估训练模型的性能。该时间段紧跟在训练数据集区间之后。结果呈现如下。

在测试期间,该模型执行了 15 笔交易,其中 10 笔获利 — 成功率超过 66%。相当不错的结果。值得注意的是,平均盈利交易是平均亏损的四倍。这结果在余额图表中明显呈上升趋势。



结束语

在这两篇文章中,我们探讨了 StockFormer 框架,其提供了一种训练金融市场交易策略的创新方法。StockFormer 结合了预测编码与强化学习,能够开发灵活的政策,即捕获多个资产之间的动态依赖关系,并预测其短期和长期行为。

StockFormer 中的三分支预测编码结构,能够提取反映短期趋势、长期变化、及资产内在关系的潜在表现。这些表现的集成是通过多头注意力模块级联达成的,从而为优化交易决策创建了统一的状态空间。

在实践部分,我们实现了框架关键组件的 MQL5 版本,训练了模型,并依据真实历史数据对其进行了测试。实验结果确认了所提议方式的有效性。无论如何,在实盘交易中应用这些模型需要依据更大的历史数据集进行训练,并进行全面的深入测试。


参考


文章中所用程序

# 名称 类型 说明
1 Research.mq5 智能系统 收集样本的智能系统
2 ResearchRealORL.mq5
智能系统
利用 Real-ORL 方法收集样本的智能系统
3 Study1.mq5  智能系统 预测学习智能系统
4 Study2.mq5  智能系统 政策训练智能系统
5 Test.mq5 智能系统 模型测试智能系统
6 Trajectory.mqh 类库 系统状态和模型架构描述结构
7 NeuroNet.mqh 类库 创建神经网络的类库
8 NeuroNet.cl 代码库 OpenCL 程序代码库

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

附加的文件 |
MQL5.zip (2253.87 KB)
开发回放系统(第 78 部分):新 Chart Trade(五) 开发回放系统(第 78 部分):新 Chart Trade(五)
在本文中,我们将研究如何实现部分接收方代码。在这里我们将实现一个 EA 交易来测试和了解协议交互是如何工作的。此处提供的内容仅用于教育目的。在任何情况下,除了学习和掌握所提出的概念外,都不应出于任何目的使用此应用程序。
MQL5 交易工具包(第 4 部分):开发历史管理 EX5 库 MQL5 交易工具包(第 4 部分):开发历史管理 EX5 库
通过详细的分步方法创建扩展的历史管理 EX5 库,学习如何使用 MQL5 检索、处理、分类、排序、分析和管理已平仓头寸、订单和交易历史。
基于Python与MQL5的特征工程(第三部分):价格角度(2)——极坐标(Polar Coordinates)法 基于Python与MQL5的特征工程(第三部分):价格角度(2)——极坐标(Polar Coordinates)法
在本文中,我们将第二次尝试将任意市场的价格水平变化转化为对应的角度变化。此次,我们选择了比首次尝试更具数学复杂性的方法,而获得的结果表明,这一调整或许是正确的决策。今天,让我们共同探讨如何通过极坐标以有意义的方式计算价格水平变化所形成的角度,无论您分析的是何种市场。
价格行为分析工具包开发(第11部分):基于Heikin Ashi(平均K线)信号的智能交易系统(EA) 价格行为分析工具包开发(第11部分):基于Heikin Ashi(平均K线)信号的智能交易系统(EA)
MQL5为开发者提供了无限可能,助您构建高度定制化的自动化交易系统。您是否知道,它甚至能执行复杂的数学运算?本文将介绍如何将日本Heikin-Ashi(平均K线)技术转化为自动化交易的策略。