English Русский Español Português
preview
交易中的神经网络:面向自适应智能体行为的技能层次结构(完结篇)

交易中的神经网络:面向自适应智能体行为的技能层次结构(完结篇)

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

引言

在前一篇文章中,我们探讨了HiSSD 框架(分层独立技能发现)的理论基础。这是一种用于多智能体系统离线训练的现代方法,可使其在复杂、高动态环境中自主运行。该框架能让智能体学习高效的交互模式,并针对环境变化自适应调整。HiSSD最初是在仿真环境中进行测试的,但其架构与设计理念使其尤其适用于金融市场 —— 市场环境往往在几秒内就发生剧烈变化,而智能交易算法需要快速、协同地做出响应。

HiSSD的核心优势之一是高度自适应能力。在交易场景中,经济指标、市场参与者行为或新闻事件都可能瞬间改变市场格局,而基于HiSSD训练的智能体无需完整重新训练即可即时调整。这一能力源于其双层技能分解架构:通用技能与任务专属技能。通用技能是可以广泛适用于多种场景的行为模式,例如趋势识别或风险评估这类行为模式。任务专属技能则负责在特殊或高度专业化的条件下控制行为。这种双层结构使得HiSSD智能体在市场状态不断切换时仍能保持稳定与有效。

HiSSD的另一大优势是可扩展性。金融市场本身就是一个典型的多智能体系统。每一位参与者 —— 无论是人工交易者、算法系统,还是大型做市商 —— 都会影响整体市场动态。在这类环境中,系统必须能够扩展,同时不破坏内部一致性。HiSSD采用分层架构,每个智能体可通过共享控制模块与其他智能体协同行为,这在设计复杂策略时尤为实用。在实际应用中,它可以用来构建更稳健、更具韧性的交易系统。

下图为作者绘制的HiSSD框架架构示意图:

在前一篇文章的实战部分中,我们已经开始基于作者提出的框架,在MQL5中实现我们自己的版本。其中,我们重点实现了一个通用技能编码器,并封装在CNeuronSkillsEncoder类中。在本文中,我们将继续完善这一工作直至完成,并在真实历史数据上测试该实现方案的效果。

在继续之前,我们先再次回顾HiSSD框架的结构。它主要由两大模块组成:规划器(Planner)和控制器(Controller)。

规划器采用线性信息流结构。原始输入数据先经过通用技能编码器,再传入预测模块,由该模块负责对未来状态与期望收益进行预测。这一架构可以使用现有工具,以标准线性模型的形式实现。

控制器的结构则更为复杂。它包含一个面向智能体的动作解码器,其输入来自三个部分:智能体本地观测值、通用技能、以及由不同编码器生成的任务专属技能。这一结构促使我们将控制器实现为一个独立的专用对象。



控制器对象

我们实现工作的下一步,是构建控制器对象,并将其封装在CNeuronHiSSDLowLevelController类中。

class CNeuronHiSSDLowLevelControler:  public CNeuronConvOCL
  {
protected:
   uint                       iTaskSkills;
   uint                       iCommonSkills;
   //---
   CNeuronSkillsEncoder       cTaskSpecificSkillsEncoder;
   CNeuronTransposeOCL        cTranspose;
   CNeuronBaseOCL             cObservAndSkillsConcat;
   CNeuronBatchNormOCL        cNormalizarion;
   CNeuronConvOCL             cActionDecoder[2];
   //---
   virtual bool               feedForward(CNeuronBaseOCL *NeuronOCL) override { return false; }
   virtual bool               feedForward(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput) override;
   virtual bool               updateInputWeights(CNeuronBaseOCL *NeuronOCL) override { return false; }
   virtual bool               updateInputWeights(CNeuronBaseOCL *NeuronOCL, CBufferFloat *second) override;
   virtual bool               calcInputGradients(CNeuronBaseOCL *prevLayer) override { return false; }
   virtual bool               calcInputGradients(CNeuronBaseOCL *NeuronOCL,
                                                 CBufferFloat *SecondInput,
                                                 CBufferFloat *SecondGradient,
                                                 ENUM_ACTIVATION SecondActivation = None
                                                ) override;

public:
                              CNeuronHiSSDLowLevelControler(void) {};
                             ~CNeuronHiSSDLowLevelControler(void) {};
   //---
   virtual bool               Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                                   uint time_step, uint variables,
                                   uint task_skills, uint common_skills, uint n_actions,
                                   uint window, uint step, uint window_key, uint heads,
                                   ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual int                Type(void) override const   {  return defNeuronHiSSDLowLevelControler;   }
   //---
   virtual bool               Save(int const file_handle) override;
   virtual bool               Load(int const file_handle) override;
   //---
   virtual bool               WeightsUpdate(CNeuronBaseOCL *source, float tau) override;
   virtual void               SetOpenCL(COpenCLMy *obj) override;
  };

需要重点说明的是,HiSSD控制器内部的智能体行为解码器,是面向多个独立智能体并行执行而设计的。这一功能可以通过串行卷积层来实现。由于解码器位于模块的输出端,最终解码层的功能可以委托给父类处理。因此,我们选择卷积层对象作为控制器模块的基类。

在新对象的结构中,我们定义了多个内部组件,其具体功能将在实现前向传播与反向传播算法时详细说明。在当前阶段,我们只需要完成这些组件的静态声明,这样构造函数和析构函数就可以保持为空。所有内部对象(包括继承而来的对象)的初始化,均在Init方法中完成。

与之前一样,初始化方法接收一组参数,用于唯一确定所需对象的架构。

bool CNeuronHiSSDLowLevelControler::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                                         uint time_step, uint variables, uint task_skills,
                                         uint common_skills, uint n_actions, uint window,
                                         uint step, uint window_key, uint heads,
                                         ENUM_OPTIMIZATION optimization_type, uint batch)
  {
   if(!CNeuronConvOCL::Init(numOutputs, myIndex, open_cl, window_key, window_key,
                            n_actions, 1, variables, optimization_type, batch))
      return false;
   SetActivationFunction(SIGMOID);

在方法内部,我们首先按照惯例调用父类的对应方法。这里有几个关键点需要强调说明。

首先,我们计划将父类功能用作智能体动作解码器的最终输出层。因此,父类方法的输入将由内部解码器各组件处理后的输出构成。解码器需要组织并行信息流,以形成各个智能体独立的“意识”表征。因此,卷积层的数据窗口大小与步幅,均设置为单个智能体内部信息向量的维度。

在输出端,我们期望得到一个智能体行为张量。因此,卷积核(滤波器)的数量与单个智能体行为向量的维度保持一致。

还有一点需要说明。为了让每个智能体实现完全独立学习,必须为其分配专属的权重参数。实现方式是:将输入序列维度设置为1,并把需要训练的智能体数量,移入定义单元序列数量的参数中。这一简洁的设计技巧,让我们可以支持任意数量独立智能体的完全并行运行。

接下来,我们开始初始化类结构中声明的内部对象。如前所述,继承而来的组件已通过之前调用的父类方法完成初始化。

第一个需要初始化的组件是任务专属技能编码器,我们将使用前一篇文章中开发的通用技能编码器来实现它。

int index = 0;
if(!cTaskSpecificSkillsEncoder.Init(0, index, OpenCL, time_step, variables, task_skills,
                                    window, step, window_key, heads, optimization, iBatch))
   return false;
cTaskSpecificSkillsEncoder.SetActivationFunction(None);

之后我们存储必要的架构常量。

iTaskSkills = task_skills;
iCommonSkills = MathMax(common_skills, 1);

需要注意的是,专属技能维度会保持原样,而通用技能维度则会被赋予一个最小有效值。原因很简单,我们之前已经在编码器初始化方法的参数中传入了任务专属技能的维度,该方法的成功执行也验证了该值的有效性。然而,通用技能张量是由规划器提供的,在本实现中,规划器是一个独立对象,甚至是一个独立模型。因此,这里只能定义一个可接受的最小约束。

接下来,我们开始构建智能体动作解码器。HiSSD框架的作者为解码器设计了三类输入源:

  • 智能体本地观测值
  • 通用技能
  • 任务专属技能

如前所述,通用技能由另一个独立模型通过辅助信息流传入。专属技能则由编码器基于主输入流的本地观测值生成。至此,所有所需数据均已具备,只需要将它们合并为统一结构即可。

需要重点强调的是,每个智能体都必须拥有独立的数据表示,因此必须保证拼接方式正确。技能张量以抽象矩阵形式表示,每一行对应一个智能体的技能向量。按行拼接后,即可得到适合卷积层并行处理独立信息流的目标矩阵。

本地观测值的处理略有不同。如前所述,输入为多模态时间序列。每个智能体仅处理自身的单变量序列。在与技能张量拼接前,观测矩阵需要先转置为便于处理单变量序列的格式。

index++;
if(!cTranspose.Init(0, index, OpenCL, time_step, variables, optimization, iBatch))
   return false;

接下来,我们计算单个智能体输入向量的维度,并为拼接后的张量初始化缓冲区。

uint window_size = (time_step + iTaskSkills + iCommonSkills);
index++;
if(!cObservAndSkillsConcat.Init(0, index, OpenCL, window_size * iVariables,
                                optimization, iBatch))
   return false;
cObservAndSkillsConcat.SetActivationFunction(None);

这里再次强调,数据来自三个不同的来源。为保证数值尺度的兼容性,我们使用批量归一化以协调它们的分布特性。

index++;
if(!cNormalizarion.Init(0, index, OpenCL, cObservAndSkillsConcat.Neurons(),
                        iBatch, optimization))
   return false;
cNormalizarion.SetActivationFunction(None);

完成数据预处理后,我们开始构建动作解码器的神经层。这里我们创建一个循环,在循环体内初始化解码器的各卷积层。其实现原理已在父类初始化部分做过说明。

   for(uint i = 0; i < cActionDecoder.Size(); i++)
     {
      index++;
      if(!cActionDecoder[i].Init(0, index, OpenCL, window_size, window_size, window_key,
                                 1, iVariables, optimization, iBatch))
         return false;
      cActionDecoder[i].SetActivationFunction(SoftPlus);
      window_size = window_key;
     }
//---
   return true;
  }

方法最后向调用程序返回本次操作的逻辑执行结果。

接下来,我们在feedForward方法中构建前向传播算法。如前所述,我们使用两路数据源进行运算。主输入流:接收描述环境状态的多模态时间序列;辅助流:接收通用技能张量。

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

首先,我们校验通用技能张量指针的有效性。与此同时,我们不单独校验主输入指针,而是将其直接传入任务专属技能编码器的对应方法中,由该编码器内部自行完成合法性检查。

if(!cTaskSpecificSkillsEncoder.FeedForward(NeuronOCL))
   return false;

生成任务专属技能张量后,我们先对环境观测张量做转置,

if(!cTranspose.FeedForward(NeuronOCL))
   return false;
if(!Concat(cTranspose.getOutput(), cTaskSpecificSkillsEncoder.getOutput(), SecondInput,
           cObservAndSkillsConcat.getOutput(), cTranspose.GetCount(),
           iTaskSkills, iCommonSkills, iVariables))
   return false;

对处理后的数据进行归一化,再送入三层结构的智能体动作解码器,最终输出所有智能体的拼接动作张量。

   if(!cNormalizarion.FeedForward(cObservAndSkillsConcat.AsObject()))
      return false;
   CNeuronBaseOCL *neuron = cNormalizarion.AsObject();
   for(uint i = 0; i < cActionDecoder.Size(); i++)
     {
      if(!cActionDecoder[i].FeedForward(neuron))
         return false;
      neuron = cActionDecoder[i].AsObject();
     }
//---
   return CNeuronConvOCL::feedforward(neuron);
  }

方法最后向调用程序返回本次操作的逻辑执行结果。

接下来我们进入反向传播流程。它分为两个阶段:

  • 根据各组件对模型最终输出的贡献程度,将误差梯度分配到所有参与计算的模块中
  • 优化模型参数以减小误差

第一阶段在calcInputGradients方法中实现。该方法接收指向输入数据流和对应误差梯度的指针。我们会立即对这些指针进行合法性校验。

bool CNeuronHiSSDLowLevelControler::calcInputGradients(CNeuronBaseOCL *NeuronOCL,
                                                       CBufferFloat *SecondInput,
                                                       CBufferFloat *SecondGradient,
                                                       ENUM_ACTIVATION SecondActivation = -1)
  {
   if(!NeuronOCL || !SecondGradient)
      return false;

梯度传播的结构与前向传播完全一致,只是顺序相反。前向传播的最后一步是动作解码器。因此,反向传播从这里开始,按逆序遍历各卷积层。

   uint total = cActionDecoder.Size();
   if(total <= 0)
      return false;
   CObject *neuron = cActionDecoder[total - 1].AsObject();
//---
   if(!CNeuronConvOCL::calcInputGradients(neuron))
      return false;
   for(int i = int(total - 2); i >= 0; i--)
     {
      if(!cActionDecoder[i].calcHiddenGradients(neuron))
         return false;
      neuron = cActionDecoder[i].AsObject();
     }

最终得到的梯度会经过归一化层,反向传递至由三类数据源拼接而成的张量层级。

if(!cNormalizarion.calcHiddenGradients(neuron))
   return false;
if(!cObservAndSkillsConcat.calcHiddenGradients(cNormalizarion.AsObject()))
   return false;

接下来,我们通过拆分数据(逆拼接)操作,将误差梯度重新分配至最初的三个输入流。

if(!DeConcat(cTranspose.getGradient(), cTaskSpecificSkillsEncoder.getGradient(), SecondGradient,
             cObservAndSkillsConcat.getGradient(), cTranspose.GetCount(),
             iTaskSkills, iCommonSkills, iVariables))
   return false;

需要重点注意的是,每个数据流都可能拥有独立的激活函数。因此,我们会在所有信息流中检查是否存在激活层,并在需要时通过对应的导数对梯度进行修正。

if(SecondActivation != None)
  {
   if(!DeActivation(SecondInput, SecondGradient, SecondGradient, SecondActivation))
      return false;
  }
if(NeuronOCL.Activation() != None)
  {
   if(!DeActivation(cTranspose.getOutput(), cTranspose.getGradient(),
                    cTranspose.getGradient(), NeuronOCL.Activation()))
      return false;
  }
if(cTaskSpecificSkillsEncoder.Activation() != None)
  {
   if(!DeActivation(cTaskSpecificSkillsEncoder.getOutput(), cTaskSpecificSkillsEncoder.getGradient(),
                    cTaskSpecificSkillsEncoder.getGradient(), cTaskSpecificSkillsEncoder.Activation()))
      return false;
  }

在此阶段,梯度已完整反向传播回辅助数据流,可视为已完成该部分的处理。剩下的工作任务是从辅助路径和主路径中,汇总所有影响主输入流的梯度。我们先将数据传入任务专属技能编码器。

if(!NeuronOCL.calcHiddenGradients(cTaskSpecificSkillsEncoder.AsObject()))
   return false;

随后替换主输入流的梯度缓冲区指针,并继续沿转置模块输出的第二条流传播梯度。

   CBufferFloat *temp = NeuronOCL.getGradient();
   if(!NeuronOCL.SetGradient(cTranspose.getPrevOutput(), false) ||
      !NeuronOCL.calcHiddenGradients(cTranspose.AsObject()) ||
      !SumAndNormilize(temp, NeuronOCL.getGradient(), temp, iVariables, false, 0, 0, 0, 1) ||
      !NeuronOCL.SetGradient(temp, false))
      return false;
//---
   return true;
  }

最后,将两个流的梯度相加,并将所有缓冲区指针都恢复至原始状态。

该方法通过向调用者返回执行结果来结束。

这样一来,我们对HiSSD框架解释中控制器实现的说明已经完成。新对象及其方法的完整源代码在附件中提供,供独立审查。



模型架构

完成HiSSD框架各个组件的构建后,我们继续描述可训练模型的架构。需要注意的是,我们计划总共训练四个模型。

第一个模型是环境状态编码器(Environmental State Encoder),在本框架中它承担 HiSSD规划器的角色。我们计划以监督学习的方式对其进行训练。它从观测到的环境状态中生成智能体的通用技能,并基于这些技能,在指定的规划时域内对未来环境状态进行预测。

这里可以看出与HiSSD原始设计略有不同 —— 原版仅对下一状态进行单步预测。然而,我们的目标是构建能够开仓并长期持有的交易策略,这需要更深入的分析与更长周期的规划。

第二个模型是控制器,它对当前环境状态进行分析,并输出多个智能体的动作张量。

第三个模型是管理器(Actor),在我们的实现中,该模型分析账户状态,评估由控制器智能体给出的交易动作,并最终决定是否执行交易。

第四个模型是一个预测网络,用于估计未来价格运行方向的概率。

所有可训练模型的架构,均在CreateDescriptions方法中定义。

bool CreateDescriptions(CArrayObj *&encoder, 
                        CArrayObj *&task, 
                        CArrayObj *&actor, 
                        CArrayObj *&probability)
  {
//---
   CLayerDescription *descr;
//---
   if(!encoder)
     {
      encoder = new CArrayObj();
      if(!encoder)
         return false;
     }
   if(!task)
     {
      task = new CArrayObj();
      if(!task)
         return false;
     }
   if(!actor)
     {
      actor = new CArrayObj();
      if(!actor)
         return false;
     }
   if(!probability)
     {
      probability = new CArrayObj();
      if(!probability)
         return false;
     }

该方法接收指向四个动态数组的指针,这些数组用于存储对应模型的架构描述。所有指针都会经过合法性校验,并在需要时创建对应对象的新实例。

我们首先定义环境状态编码器的架构。与之前一样,模型以一个容量足够的全连接层开始,用于对原始输入数据进行嵌入编码。

//--- Encoder
   encoder.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   int prev_count = descr.count = (HistoryBars * BarDescr);
   descr.activation = None;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

该模型接收到未经处理的环境观测数据后,首先通过一个批量归一化层对数据进行标准化。

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

随后连接一个通用技能编码器,在当前结构中,其负责生成通用技能张量。

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronSkillsEncoder;
   descr.count = HistoryBars;
     {
      int temp[] = {BarDescr, NSkills, 4};   // Variables, Common Skills, Heads
      if(ArrayCopy(descr.windows, temp) < (int)temp.Size())
         return false;
     }
   descr.window = 8;
   descr.step = 1;
   descr.window_out = 32;
   prev_count = descr.windows[0];
   int prev_out = descr.windows[1];
   descr.batch = 1e4;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

接下来,使用两层卷积层,在通用技能张量的条件约束下,对多模态时间序列中各单元序列的未来走势进行预测。

//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   descr.count = 1;
   descr.window = prev_out;
   descr.step = prev_out;
   prev_out=descr.window_out = 4*NForecast;
   descr.layers = prev_count;
   descr.activation = SoftPlus;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   descr.count = 1;
   descr.window = prev_out;
   descr.step = prev_out;
   prev_out=descr.window_out = NForecast;
   descr.layers = prev_count;
   descr.activation = TANH;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

需要重点强调的是,每个单元序列的预测行为,是由单个智能体的通用技能向量在给定规划时域上构建出来的。因此,该规划模块的输出与常规的多模态时间序列表示形式不同。为恢复为统一格式,必须对数据进行转置。

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

此后,经过变换的特征表示会通过逆归一化处理,还原到原始数据分布空间。

//--- layer 6
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronRevInDenormOCL;
   descr.count = prev_count*prev_out;
   descr.layers = 1;
   descr.activation = None;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

至此,已完成环境状态编码器的架构定义。在继续定义其他模型之前,我们先保存包含输出通用技能张量的隐层特征对象。

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

如上所述,第二个模型是控制器。它基于相同的环境状态表示生成智能体的任务专属技能,因此我们复用了上一个模型的前两层。

//--- Task
   task.Clear();
//--- Input layer
   if(!task.Add(encoder.At(0)))
     {
      return false;
     }
//--- layer 1
   if(!task.Add(encoder.At(1)))
     {
      return false;
     }

接下来,模型由先前构建好的控制器模块完成收尾。

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronHiSSDLowLevelControler;
   descr.count = HistoryBars;
     {
      int temp[] = {latent.windows[0], // Variables
                    NSkills,           // Task Skills
                    latent.windows[1], // Common Skills
                    NActions,          // Action Space
                    4};                // Heads
      if(ArrayCopy(descr.windows, temp) < (int)temp.Size())
         return false;
     }
   descr.window = 8;
   descr.step = 1;
   descr.window_out = 32;
   prev_count = descr.windows[0];
   prev_out = descr.windows[3];
   descr.batch = 1e4;
   descr.optimization = ADAM;
   descr.activation = SIGMOID;
   if(!task.Add(descr))
     {
      delete descr;
      return false;
     }

第三个模型为更高层级的管理器,其输入为账户状态向量。模型使用一个全连接层对该信息进行嵌入编码。

//--- Actor
   actor.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   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 = AccountDescr;
   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 = defNeuronCrossDMHAttention;
     {
      int temp[] = {AccountDescr,    // Inputs window
                    prev_out         // Cross window
                   };
      if(ArrayCopy(descr.windows, temp) < (int)temp.Size())
         return false;
     }
     {
      int temp[] = {1,              // Inputs units
                    prev_count      // Cross units
                   };
      if(ArrayCopy(descr.units, temp) < (int)temp.Size())
         return false;
     }
   descr.step = 4;                  // Heads
   descr.window_out = 32;
   descr.batch = 1e4;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

随后连接三层全连接层,构成决策头。该结构将提取到的特征转化为执行者(Actor)的最终动作向量。

//--- 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 = SoftPlus;
   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
   probability.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = latent.windows[0] * latent.windows[1];
   descr.activation = latent.activation;
   descr.optimization = ADAM;
   if(!probability.Add(descr))
     {
      delete descr;
      return false;
     }

该网络以多层感知器形式实现,包含两个隐藏全连接层,层间使用非线性激活函数以提升特征表达能力。最后一层采用Sigmoid激活函数,输出价格运行方向的概率值。

//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 2 * LatentCount;
   descr.activation = SoftPlus;
   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 = SIGMOID;
   descr.batch = 1e4;
   descr.optimization = ADAM;
   if(!probability.Add(descr))
     {
      delete descr;
      return false;
     }
//---
   return true;
  }

在完成所有可训练模型的架构定义后,该方法返回一个布尔型状态值,用于标识是否执行成功。



模型训练

至此,我们已经完成了基于HiSSD框架系统的绝大部分实现工作,下面将对这四个模型进行训练。按照该框架作者提出的方案,所有模型采用离线模式同步联合训练。这里,使用了我们在前期工作中采集好的训练样本集。

需要再次说明的是,该数据集基于2024年全年真实的EURUSD(欧元兑美元)M1历史行情数据构建,指标均采用默认参数。

然而,关于数据集构建的细节,我们会在后续再展开说明。当前,我们先聚焦于训练流程本身。同时训练四个相互作用的模型,需要对智能交易系统(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()
      || !Task.Clear()
      || !Actor.Clear()
   )
     {
      PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
      Stop = true;
      break;
     }
   result = vector<float>::Zeros(NActions);

外层循环遍历训练批次。对于每个批次,从经验回放缓冲区中采样一条轨迹,并在该轨迹内随机选取一个起始点。内层循环则按顺序遍历所选片段内的环境状态。

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();

至此,输入数据准备完毕,我们开始对所有模型执行前向传播。首先,调用环境状态编码器的前向传播,并将此前准备好的对应数据缓冲区传入。

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

接下来轮到控制器,它除了对环境状态进行解析外,还会对来自编码器隐空间的通用技能进行分析。

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

管理器(Manager)同时接收账户状态向量,以及代表多个候选交易动作的控制器输出。

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

价格方向预测模型仅使用从编码器隐状态中提取的通用技能进行运算。

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

至此,所有模型均已完成输入数据的分析并输出了对应结果。下一步是将这些输出与目标值进行对比 —— 但这些目标值从何而来?

对于编码器而言,期望输出是对未来环境状态的预测。因此,目标张量通过从数据集中提取真实未来状态,并按合适的顺序排列来构建。

//--- Look for target
target = vector<float>::Zeros(NActions);
bActions.AssignArray(target);
if(!state.Assign(Buffer[tr].States[i + NForecast].state) ||
   !state.Resize(NForecast * BarDescr) ||
   MathAbs(state).Sum() == 0)
  {
   iter -= Batch + start - i;
   break;
  }
if(!fstate.Resize(1, NForecast * BarDescr) ||
   !fstate.Row(state, 0) ||
   !fstate.Reshape(NForecast, BarDescr))
  {
   iter -= Batch + start - i;
   break;
  }
for(int j = 0; j < NForecast / 2; j++)
  {
   if(!fstate.SwapRows(j, NForecast - j - 1))
     {
      PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
      Stop = true;
      break;
     }
  }

这些数值可以作为目标值传入环境状态编码器,之后对模型参数进行调整,以最小化预测误差。

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

同样的未来状态原始数据也会被其他模型复用,但使用方式更为复杂。为达成最优交易动作,我们不仅参考未来价格走势(数据集中已提供),还会结合当前持仓状态。如果存在持仓,模型会关注平仓信号,且多单与空单的平仓信号各不相同。

target = fstate.Col(0).CumSum();
if(result[0] > result[3])
  {
   float tp = 0;
   float sl = 0;
   float cur_sl = float(-(result[2] > 0 ? result[2] : 1) * MaxSL * Point());
   int pos = 0;
   for(int j = 0; j < NForecast; j++)
     {
      tp = MathMax(tp, target[j] + fstate[j, 1] - fstate[j, 0]);
      pos = j;
      if(cur_sl >= target[j] + fstate[j, 2] - fstate[j, 0])
         break;
      sl = MathMin(sl, target[j] + fstate[j, 2] - fstate[j, 0]);
     }
   if(tp > 0)
     {
      sl = (float)MathMax(MathMin(MathAbs(sl) / (MaxSL * Point()), 1), 0.01);
      tp = float(MathMin(tp / (MaxTP * Point()), 1));
      result[0] = MathMax(result[0] - result[3], 0.011f);
      result[5] = result[1] = tp;
      result[4] = result[2] = sl;
      result[3] = 0;
      bActions.AssignArray(result);
     }
  }
else
  {
   if(result[0] < result[3])
     {
      float tp = 0;
      float sl = 0;
      float cur_sl = float((result[5] > 0 ? result[5] : 1) * MaxSL * Point());
      int pos = 0;
      for(int j = 0; j < NForecast; j++)
        {
         tp = MathMin(tp, target[j] + fstate[j, 2] - fstate[j, 0]);
         pos = j;
         if(cur_sl <= target[j] + fstate[j, 1] - fstate[j, 0])
            break;
         sl = MathMax(sl, target[j] + fstate[j, 1] - fstate[j, 0]);
        }
      if(tp < 0)
        {
         sl = (float)MathMax(MathMin(MathAbs(sl) / (MaxSL * Point()), 1), 0.01);
         tp = float(MathMin(-tp / (MaxTP * Point()), 1));
         result[3] = MathMax(result[3] - result[0], 0.011f);
         result[2] = result[4] = tp;
         result[1] = result[5] = sl;
         result[0] = 0;
         bActions.AssignArray(result);
        }
     }

如果当前无持仓,则模型转而关注入场时机。我们首先确定预期价格波动的方向与幅度。

else
  {
   ulong argmin = target.ArgMin();
   ulong argmax = target.ArgMax();
   float max_sl = float(MaxSL * Point());
   while(argmax > 0 && argmin > 0)
     {
      if(argmax < argmin && target[argmax] / 2 > MathAbs(target[argmin]) &&
                                           MathAbs(target[argmin]) < max_sl)
         break;
      if(argmax > argmin && target[argmax] < MathAbs(target[argmin] / 2) && 
                                                   target[argmax] < max_sl)
         break;
      target.Resize(MathMin(argmax, argmin));
      argmin = target.ArgMin();
      argmax = target.ArgMax();
     }

随后,我们定义交易参数。

if(argmin == 0 || (argmax < argmin && argmax > 0))
  {
   float tp = 0;
   float sl = 0;
   float cur_sl = - float(MaxSL * Point());
   ulong pos = 0;
   for(ulong j = 0; j < argmax; j++)
     {
      tp = MathMax(tp, target[j] + fstate[j, 1] - fstate[j, 0]);
      pos = j;
      if(cur_sl >= target[j] + fstate[j, 2] - fstate[j, 0])
         break;
      sl = MathMin(sl, target[j] + fstate[j, 2] - fstate[j, 0]);
     }
   if(tp > 0)
     {
      sl = (float)MathMax(MathMin(MathAbs(sl) / (MaxSL * Point()), 1), 0.01);
      tp = (float)MathMin(tp / (MaxTP * Point()), 1);
      result[0] = float(MathMax(Buffer[tr].States[i].account[0] / 100 * 0.01, 0.011));
      result[5] = result[1] = tp;
      result[4] = result[2] = sl;
      result[3] = 0;
      bActions.AssignArray(result);
     }
  }
    else
      {
       if(argmax == 0 || argmax > argmin)
         {
          float tp = 0;
          float sl = 0;
          float cur_sl = float(MaxSL * Point());
          ulong pos = 0;
          for(ulong j = 0; j < argmin; j++)
            {
             tp = MathMin(tp, target[j] + fstate[j, 2] - fstate[j, 0]);
             pos = j;
             if(cur_sl <= target[j] + fstate[j, 1] - fstate[j, 0])
                break;
             sl = MathMax(sl, target[j] + fstate[j, 1] - fstate[j, 0]);
            }
          if(tp < 0)
            {
             sl = (float)MathMax(MathMin(MathAbs(sl) / (MaxSL * Point()), 1), 0.01);
             tp = (float)MathMin(-tp / (MaxTP * Point()), 1);
             result[3] = float(MathMax(Buffer[tr].States[i].account[0] / 100 * 0.01, 0.011));
             result[2] = result[4] = tp;
             result[1] = result[5] = sl;
             result[0] = 0;
             bActions.AssignArray(result);
            }
         }
      }
   }
}

最终得到的“最优交易动作”仅用于训练管理器

//--- Actor Policy
if(!Actor.backProp(GetPointer(bActions), (CNet*)GetPointer(Task), -1))
  {
   PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
   Stop = true;
   break;
  }

接下来,我们为控制器构建目标张量。很自然的思路是直接使用上述最优交易动作的参数作为目标。然而,该动作中包含持仓仓位,而控制器仅通过环境状态分析,无法独立确定这一参数。除此之外,我们还需要只有管理器才能获取的账户状态信息。因此,我们将绝对交易手数替换为盈利概率。对于一笔最优交易而言,该概率直接设置为1。

//--- Agents
target=result;
if(target[0] > 0)
   target[0] = 1;
if(target[3] > 0)
   target[3] = 1;

调整后的最优交易动作会在所有智能体间复制,并作为控制器的训练目标。

Result.Clear();
for(int i = 0; i < BarDescr; i++)
  {
   if(!Result.AddArray(target))
     {
      PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
      Stop = true;
      break;
     }
  }
if(!Task.backProp(Result, (CNet*)GetPointer(Encoder), LatentLayer)
  )
  {
   PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
   Stop = true;
   break;
  }

需要重点强调的是,所有智能体都会收到相同的目标值。然而,我们并不要求它们产生同步行为,因为每个智能体仅处理来自自身单变量时间序列的局部观测结果。因此,多个智能体对所分析环境状态的差异化解读,有可能为管理器提供更强的信号。

最后,我们为价格方向预测模型定义目标值。这里,会再次用到未来环境状态数据。我们计算规划周期内的累计价格变动,并识别其中的最大波动幅度。这一最大偏移的方向即为主导趋势,用于训练该预测模型。

//--- Probability
target = vector<float>::Zeros(NActions / 3);
vector<float> trend=fstate.Col(0).CumSum();
ulong argmax=MathAbs(trend).ArgMax();
if(trend[argmax] > 0)
   target[0] = 1;
else
   if(trend[argmax] < 0)
      target[1] = 1;
if(!Result.AssignArray(target)
   || !Probability.backProp(Result, (CNet*)GetPointer(Encoder),LatentLayer)
   || !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("%-12s %6.2f%% -> Error %15.8f\n", "Encoder",
                                     percent, Encoder.getRecentAverageError());
       str += StringFormat("%-14s %6.2f%% -> Error %15.8f\n", "Task", percent,
                                                 Task.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__, "Encoder", 
                                                 Encoder.getRecentAverageError());
   PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Task", 
                                                    Task.getRecentAverageError());
   PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Actor",
                                                   Actor.getRecentAverageError());
   PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Probability",
                                             Probability.getRecentAverageError());
   ExpertRemove();
//---
  }

该程序的完整代码可在附件中查看。附件还包含用于采集训练样本、测试训练完成模型的相关程序。关于这些程序中的具体修改细节,建议您自行探索。



测试

我们现在进入其中一个最关键的阶段:在真实历史数据上评估所提方法的有效性。如之前所述,模型训练使用的是2024年全年市场数据。

为客观评估所学习策略的质量,我们在MetaTrader 5策略测试器中,使用2025年1至3月的样本外数据对训练好的模型进行测试。为保证一致性和可比性,包括市场环境、时间周期、模拟配置在内的其余所有参数均保持不变。

测试结果呈现如下。

在为期三个月的测试周期内,模型共执行860笔交易,其中盈利交易340笔,胜率为39.53%。尽管如此,单笔盈利交易的平均收益比平均亏损高出约70%,使得该策略整体仍保持盈利。

另外值得注意的是,测试期三个月均以盈利结束。



结论

本文研究了适配量化交易任务的HiSSD框架。其核心思想 —— 将技能分解为通用技能与任务专属技能,在高度动态的市场环境中被验证有效。该结构使智能体能够快速适应环境变化,无需重新训练。

实现过程充分考虑了金融数据特性:模型使用2024年EURUSD真实历史行情进行训练,并于2025年初在未见过的全新数据上完成测试。这使得模型在接近真实市场的条件下,其效果评估更贴合实际。

然而,需要再次强调一下,在将此类系统部署到实盘交易之前,必须在更具代表性的数据集上进行训练,并在多种市场形态下完成全面测试。


参考文献


本文中用到的程序

# 名称 类型 描述
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/17739

附加的文件 |
MQL5.zip (2622.44 KB)
交易策略 交易策略
各种交易策略的分类都是任意的,下面这种分类强调从交易的基本概念上分类。
神经网络在交易中的应用:用于智能体自适应行为的分层技能发现(HiSSD) 神经网络在交易中的应用:用于智能体自适应行为的分层技能发现(HiSSD)
本文将介绍 HiSSD 框架,该框架结合分层学习与多智能体技术构建自适应系统。我们将详细分析这套创新方法如何挖掘金融市场中的隐藏模式,并在去中心化场景下优化交易策略。
新手在交易中的10个基本错误 新手在交易中的10个基本错误
新手在交易中会犯的10个基本错误: 在市场刚开始时交易, 获利时不适当地仓促, 在损失的时候追加投资, 从最好的仓位开始平仓, 翻本心理, 最优越的仓位, 用永远买进的规则进行交易, 在第一天就平掉获利的仓位,当发出建一个相反的仓位警示时平仓, 犹豫。
外汇套利交易:带风险控制的公允价值回归矩阵交易系统 外汇套利交易:带风险控制的公允价值回归矩阵交易系统
本文详细描述了交叉汇率计算算法,展示了不平衡矩阵的可视化结果,并给出了优化设置 MinDiscrepancy 和 MaxRisk 参数以实现高效交易的建议。该系统使用交叉汇率自动计算每对货币的“公允价值”,在出现负偏差时生成买入信号,在出现正偏差时生成卖出信号。