English Русский 中文 Español Deutsch 日本語
preview
Redes neurais de maneira fácil (Parte 47): Espaço contínuo de ações

Redes neurais de maneira fácil (Parte 47): Espaço contínuo de ações

MetaTrader 5Sistemas de negociação | 16 outubro 2023, 16:38
454 0
Dmitriy Gizlyk
Dmitriy Gizlyk

Introdução

Em nossos trabalhos anteriores, ensinamos o agente apenas a determinar a direção das negociações. O escopo de ações do Agente estava limitado a apenas 4 opções:

  • comprar, 
  • vender, 
  • segurar/esperar,
  • fechar todas as posições.

Aqui, não vimos funções de gerenciamento de capital e riscos. Em todas as operações de negociação, usamos um lote mínimo. Isso é suficiente para avaliar abordagens de treinamento, mas não é suficiente para construir uma estratégia de negociação. Uma estratégia de negociação lucrativa deve ter um algoritmo de gerenciamento de capital.

Além disso, para criar uma estratégia de negociação estável, é necessário gerenciar os riscos. Esse componente também estava ausente em nossos desenvolvimentos. Sim, o EA avaliava a situação de mercado a cada nova vela de negociação e tomava decisões de negociação. Mas cada vela subsequente carrega riscos para nossa conta. O movimento de preços dentro de uma vela pode ser prejudicial para nosso saldo. É por isso que sempre é recomendável usar ordens de stop-loss. Esse método simples nos permite limitar os riscos em uma única negociação.


1. Características do treinamento em um espaço contínuo de ações

É lógico que, ao treinar o Agente e desenvolver sua política de negociação, precisamos levar em consideração esses aspectos. Mas aqui surge a pergunta de como treinar o modelo para prever o volume da negociação realizada e os níveis de fechamento de posição. Isso é facilmente organizado com algoritmos de aprendizado supervisionado, nos quais podemos especificar os valores-alvo necessários fornecidos pelo instrutor. No entanto, ao usar algoritmos de aprendizado por reforço, há algumas dificuldades.

Lembrando que anteriormente usamos 2 abordagens para treinar modelos com reforço: prever recompensas e a probabilidade de obter a recompensa máxima.

Uma das possíveis maneiras de resolver esse problema é definir valores discretos para todos os parâmetros da operação de negociação e criar uma ação separada para cada uma das opções possíveis. Isso nos permitirá considerar alguns aspectos do gerenciamento de capital e riscos.

No entanto, essa abordagem não está isenta de desvantagens. A escolha de parâmetros discretos para a negociação requer algum trabalho na fase de preparação de dados. Sua escolha sempre será um compromisso entre o número de opções e a flexibilidade adequada para as decisões do Agente. Além disso, o número de combinações de ações possíveis pode aumentar significativamente, o que levará a uma complexidade adicional do modelo. E, junto com isso, ao aumento do tempo de treinamento. Afinal, durante o processo de treinamento, será necessário aprender a recompensa para cada uma das ações possíveis.

Por exemplo, se considerarmos apenas 3 valores discretos para o volume da negociação, 3 níveis de stop-loss e 5 níveis de take-profit, precisaríamos de 90 elementos apenas para determinar o espaço de ação em 2 direções de negociação (3 * 3 * 5 * 2 = 90). Adicione a isso as ações de manutenção e fechamento de posição. E já temos 92 variações no espectro de ações possíveis para o agente.

Concorda que essa limitação na liberdade de ação do Agente leva a um aumento significativo no número de neurônios na saída do modelo. E, à medida que adicionamos cada valor discreto de qualquer um dos parâmetros de negociação, isso leva ao crescimento progressivo do número de neurônios.

Além disso, o treinamento de um modelo mais complexo pode exigir exemplos adicionais de conjunto de treinamento, com todas as implicações decorrentes disso.

Mas existem outras abordagens, os chamados algoritmos de treinamento de agentes em um espaço contínuo de ações. Agentes treinados com esses algoritmos podem escolher ações de um intervalo contínuo de valores. Isso permite que eles gerenciem os parâmetros da negociação, incluindo o volume de negociação, os níveis de stop-loss e take-profit, de forma mais flexível e precisa.

Um dos algoritmos mais populares para treinamento de agentes em um espaço contínuo de ações é o Deep Deterministic Policy Gradient (DDPG). No DDPG, o modelo consiste em duas redes neurais: o Ator e o Crítico. O Ator prevê a ação ótima com base no estado atual, enquanto o Crítico avalia essa ação. Já tivemos contato com uma solução semelhante no artigo "Algoritmo Ator-Crítico de Vantagem". Esses algoritmos têm semelhanças em suas abordagens, mas diferem no algoritmo de treinamento do Ator.

No DDPG, o treinamento do Ator ocorre por meio do método de gradiente ascendente para otimizar a política determinística. O Ator prevê diretamente a ação ótima com base no estado atual, em vez de modelar a distribuição de probabilidade das ações, como no algoritmo Ator-Crítico de Vantagem.

O treinamento do Ator no DDPG envolve o cálculo do gradiente da função de valor do Crítico em relação às ações do Ator e o uso desse gradiente para atualizar os parâmetros do Ator. Pode parecer um pouco complexo, mas isso permite que o Ator encontre a ação ótima que maximiza a avaliação do Crítico.

É importante observar que o DDPG pertence aos algoritmos off-policy. O modelo é treinado com base em dados coletados de interações anteriores com o ambiente, independentemente da estratégia atual de tomada de decisão. Esse atributo fundamental do algoritmo permite seu uso em ambientes complexos e estocásticos, nos quais a previsão da dinâmica do ambiente pode ser difícil ou imprecisa. Enfrentamos problemas de previsão de baixa qualidade dos mercados financeiros ao testar o algoritmo EDL.

O algoritmo Deep Deterministic Policy Gradient é fundamentado nos princípios básicos da Deep Q-Network (DQN) e incorpora muitos de seus métodos, incluindo o buffer de replay de experiência e o modelo alvo. Vamos analisar o algoritmo com mais detalhes.

Como mencionado anteriormente, o modelo consiste em 2 redes neurais: o Ator e o Crítico. O Ator recebe o estado do ambiente como entrada. A saída do Ator nos fornece uma ação de um intervalo contínuo de valores. Neste caso, estaremos determinando o volume de negociação, os níveis de stop-loss e take-profit. Dependendo da arquitetura do modelo e da formulação da tarefa, podemos usar valores absolutos ou relativos. Para aumentar a exploração do ambiente, podemos adicionar algum ruído à ação gerada pelo Ator.

Executamos a ação escolhida pelo Ator e transitamos para um novo estado do ambiente. Em resposta à ação executada, recebemos uma recompensa do ambiente.

Coletamos conjuntos de dados "Estado - Ação - Novo estado - Recompensa" no buffer de replay de experiência. Tudo conforme a tradição dos algoritmos de aprendizado por reforço.

Assim como no DQN, selecionamos um lote de dados de treinamento do buffer de replay de experiência. Os estados deste lote de dados de treinamento são fornecidos como entrada para o Ator. Antes de atualizar os parâmetros, é provável que obtenhamos uma ação semelhante à armazenada no buffer de replay de experiência. No entanto, ao contrário do Ator-Crítico de Vantagem, o Ator não retorna uma distribuição de probabilidade, mas sim uma ação de um intervalo contínuo.

Para avaliar o valor dessa ação, passamos o estado atual e a ação formada ao Crítico. Com base nos dados obtidos, o Crítico faz uma previsão da recompensa, assim como no DQN clássico.

Da mesma forma que no DQN, o Crítico é treinado para minimizar o erro quadrático médio entre a recompensa prevista e a real do buffer de replay de experiência. Para estabelecer uma política coesa, é utilizada uma rede alvo Target Net. No entanto, como o Crítico requer um conjunto de dados consistindo de estado e ação para avaliar o estado subsequente, para formar a ação a partir do estado subsequente, também utilizaremos o modelo alvo do Ator.

A peculiaridade do DDPG reside no fato de que, para treinar o Ator, não usaremos valores-alvo na saída. Em vez disso, simplesmente pegamos o valor do gradiente do erro do modelo Crítico em relação à nossa ação e o propagamos através do modelo do Ator.

Assim, durante o treinamento da função Q do Crítico, usamos o gradiente do erro em relação à ação para otimizar as ações do Agente. Pode-se dizer que o Ator é uma parte integrante da função Q. E o treinamento da função Q leva à otimização da função do Ator.

No entanto, é importante notar que, durante o treinamento do Crítico, otimizamos seus parâmetros para avaliar corretamente o par estado-ação. Enquanto no treinamento do Ator, otimizamos seus parâmetros para aumentar a recompensa prevista, mantendo todas as outras coisas iguais.

Os autores do método recomendam o uso de atualizações suaves das modelos alvo. A simples substituição da modelo alvo treinada com uma certa periodicidade é substituída pelo ajuste dos parâmetros da modelo alvo com base na taxa de atualização em direção aos parâmetros do modelo treinado. Na opinião dos autores, essa abordagem desacelera a atualização das modelos alvo, mas aumenta a estabilidade do processo de treinamento.


2. Implementação em MQL5

Após a familiarização teórica com o método Deep Deterministic Policy Gradient (DDPG), passamos à sua implementação prática com MQL5. E começaremos com a organização do processo de atualização suave das modelos alvo. A própria função de média ponderada de dois parâmetros não é complicada, mas há dois pontos a serem observados.

Primeiro, a operação deve ser executada com todos os parâmetros das modelos. E, como a operação de cada parâmetro individual não depende absolutamente de outros parâmetros da mesma modelo, eles podem ser facilmente executados em paralelo.

Segundo, todas as operações de treinamento e exploração das modelos são realizadas no contexto do OpenCL. E as operações de transferência de dados entre a memória do contexto e a memória principal são relativamente caras. Sempre procuramos minimizá-las. Logicamente, faz sentido realizar o recálculo dos parâmetros também no contexto do OpenCL.

2.1. Atualização suave dos modelos alvo

Primeiro, criamos o kernel SoftUpdate para realizar as operações. O algoritmo do kernel é bastante simples. Nos parâmetros do kernel, passamos ponteiros para 2 buffers de dados (parâmetros da modelo alvo e da modelo treinada) e o coeficiente de atualização como uma constante.

__kernel void SoftUpdate(__global float *target, 
                         __global const float *source, 
                         const float tau
                        )
  {
   const int i = get_global_id(0);
   target[i] = target[i] * tau + (1.0f - tau) * source[i];
  }

Em cada thread individual, realizaremos a atualização de apenas um parâmetro. Portanto, o número de threads será igual ao número de parâmetros a serem atualizados.

A seguir, precisamos organizar o processo no lado do programa principal.

Lembrando que os parâmetros dos modelos estão distribuídos em diferentes objetos, dependendo do tipo de camada neural. Isso significa que precisamos adicionar um método de atualização de parâmetros a cada classe que organize o funcionamento de uma camada neural. Vamos considerar isso usando o exemplo da classe base de camada neural CNeuronBaseOCL.

Uma vez que estaremos atualizando os parâmetros da camada neural atual, nos parâmetros do método, basta passar um ponteiro para a camada neural do modelo treinado e um coeficiente de atualização.

bool CNeuronBaseOCL::WeightsUpdate(CNeuronBaseOCL *source, float tau)
  {
   if(!OpenCL || !Weights || !source || !source.Weights)
      return false;

Dentro do método, verificamos a validade do ponteiro do objeto da camada neural recebido. Ao fazê-lo, também verificamos os ponteiros para os objetos internos necessários.

Aqui, também verificamos a correspondência dos tipos de duas camadas neurais e das dimensões das matrizes de parâmetros.

   if(Type() != source.Type())
      return false;
   if(Weights.Total() != source.Weights.Total())
      return false;

Após passar com sucesso pelo bloco de verificações, organizamos a transferência de parâmetros para o kernel.

   uint global_work_offset[1] = {0};
   uint global_work_size[1] = {Weights.Total()};
   ResetLastError();
   if(!OpenCL.SetArgumentBuffer(def_k_SoftUpdate, def_k_su_target, Weights.GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_SoftUpdate, def_k_su_source, source.getWeightsIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_SoftUpdate, def_k_su_tau, (float)tau))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }

E colocamos o kernel na fila de execução. Não esquecemos de controlar o processo de execução em cada etapa.

   if(!OpenCL.Execute(def_k_SoftUpdate, 1, global_work_offset, global_work_size))
     {
      printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError());
      return false;
     }
//---
   return true;
  }

E concluímos o método.

Uma vez que todos os objetos que organizam o funcionamento de várias camadas neurais herdam da classe base CNeuronBaseOCL, todos eles herdarão o método criado. No entanto, ele permite apenas a atualização da matriz de pesos da classe base. Em todas as classes com adição de objetos otimizáveis internos adicionais, o método deve ser redefinido. Por exemplo, na camada convolucional CNeuronConvOCL, adicionamos uma matriz de parâmetros de convolução. Para atualizá-la, redefiniremos o método WeightsUpdate. Para suportar a redefinição dos métodos herdados, mantemos todos os parâmetros do método inalterados.

bool CNeuronConvOCL::WeightsUpdate(CNeuronBaseOCL *source, float tau)
  {
   if(!CNeuronBaseOCL::WeightsUpdate(source, tau))
      return false;

Mas no corpo do método, não repetimos todo o bloco de verificações. Em vez disso, chamamos o método da classe pai e verificamos o resultado das operações.

Em seguida, é importante notar que nos parâmetros recebemos um ponteiro para um objeto da classe neural base. Isso é feito de propósito. Especificar o tipo da classe pai permite passar um ponteiro para qualquer um de seus descendentes. Isso é o que precisamos para organizar um método virtual em todas as classes descendentes.

Mas a questão é que, nessas condições, não podemos acessar a matriz de pesos da camada de convolução fornecida nos parâmetros. Simplesmente não há esse objeto na classe pai. Ele aparece apenas na classe da camada de convolução. E não temos dúvidas de que o ponteiro fornecido nos parâmetros se refere à camada de convolução. Afinal, no método da classe pai, verificamos a correspondência dos tipos da camada neural atual e daquela fornecida nos parâmetros. Para trabalhar com esse objeto da camada de convolução, basta atribuir o ponteiro fornecido a um objeto dinâmico da camada de convolução. E imediatamente verificamos a correspondência das dimensões das matrizes.

   CNeuronConvOCL *temp = source;
   if(WeightsConv.Total() != temp.WeightsConv.Total())
      return false;

A seguir, repetimos o procedimento de transferência de dados e colocação do kernel na fila de execução. Observe que apenas os objetos usados nos buffers de dados são alterados.

   uint global_work_offset[1] = {0};
   uint global_work_size[1] = {WeightsConv.Total()};
   ResetLastError();
   if(!OpenCL.SetArgumentBuffer(def_k_SoftUpdate, def_k_su_target, WeightsConv.GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_SoftUpdate, def_k_su_source, temp.WeightsConv.GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_SoftUpdate, def_k_su_tau, (float)tau))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.Execute(def_k_SoftUpdate, 1, global_work_offset, global_work_size))
     {
      printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError());
      return false;
     }
//---
   return true;
  }

Da mesma forma, criamos métodos em todas as outras classes de camadas neurais onde adicionamos objetos com parâmetros otimizáveis. Não vou apresentar o código completo dos métodos das classes, você pode consultá-los no anexo.

Deve-se notar que o algoritmo de funcionamento da nossa biblioteca não prevê o acesso direto do usuário às camadas neurais do modelo. O usuário sempre trabalha com a classe de alto nível do modelo de rede neural. Portanto, após adicionar métodos às classes das camadas neurais, criaremos um método com o mesmo nome em nossa classe de modelo CNet::WeightsUpdate. O método recebe um ponteiro para a rede neural treinada e um coeficiente de atualização como parâmetros. No corpo do método, organizamos um ciclo para percorrer todas as redes neurais do modelo e chamamos os métodos de atualização da camada neural. O algoritmo é bastante simples e não é necessário apresentar seu código neste artigo. Você pode consultar por conta própria no anexo.

2.2. Troca de dados entre o Ator e o Crítico

Após organizar o processo de atualização suave dos modelos, passamos diretamente para a organização do processo de treinamento do modelo. Devo dizer de imediato que nosso modelo é uma espécie de simbiose do algoritmo DDPG com abordagens previamente estudadas. Em particular, foi decidido usar um único bloco de pré-processamento de dados de entrada para ambas as redes neurais (Ator e Crítico).

Lembrando, o Ator toma a decisão sobre a ação ótima com base no estado atual do ambiente. O Crítico recebe uma descrição do estado do ambiente e a ação do Ator como entrada. Com base nesses dados, ele faz uma previsão da recompensa esperada (avalia a ação do Ator). Como pode ser visto, o Ator e o Crítico recebem a descrição do ambiente. Para minimizar operações repetitivas, decidimos organizar o bloco de pré-processamento de dados de entrada no corpo do Ator. Em relação ao Crítico, decidimos passar uma representação comprimida do estado do ambiente do estado latente do Ator. E, para minimizar o volume de transferência de dados entre o Ator e o Crítico no programa principal, decidimos criar métodos adicionais de propagação e retropropagação com a transferência não de buffers de dados individuais, mas de ponteiros diretamente para o modelo de dados de entrada e o identificador da camada com os dados de entrada.

E primeiro, vamos examinar a organização do método de propagação CNet::feedForward. Os parâmetros do método preveem a passagem de 2 ponteiros para redes neurais (dados de entrada principais e adicionais) e 2 identificadores de camadas neurais nas respectivas redes.

bool CNet::feedForward(CNet *inputNet, int inputLayer=-1, CNet *secondNet = NULL, int secondLayer = -1)
  {
   if(!inputNet || !opencl)
      return false;

Valores padrão foram adicionados aos parâmetros, o que permite o uso do método passando apenas um ponteiro para o modelo de dados de entrada principal.

No corpo do método, verificamos o ponteiro recebido para o modelo de dados de entrada principal. E, na ausência de dados, saímos do método com um resultado negativo.

Em seguida, verificamos o identificador da camada neural no modelo de dados de entrada principal. Se, por algum motivo, ele não foi especificado, usaremos a última camada neural no modelo.

   if(inputLayer<0)
      inputLayer=inputNet.layers.Total()-1;

Na etapa seguinte, organizamos o acesso aos dados adicionais. Criamos um ponteiro vazio para um objeto de buffer de dados e verificamos a validade do ponteiro para o modelo de dados de entrada adicional.

   CBufferFloat *second = NULL;
   bool del_second = false;
   if(!!secondNet)
     {
      if(secondLayer < 0)
         secondLayer = secondNet.layers.Total() - 1;
      if(secondNet.GetOpenCL() != opencl)
        {
         secondNet.GetLayerOutput(secondLayer, second);
         if(!!second)
           {
            if(!second.BufferCreate(opencl))
              {
               delete second;
               return false;
              }
            del_second = true;
           }
        }
      else
        {
         if(secondNet.layers.Total() <= secondLayer)
            return false;
         CLayer *layer = secondNet.layers.At(secondLayer);
         CNeuronBaseOCL *neuron = layer.At(0);
         second = neuron.getOutput();
        }
     }

Com um ponteiro válido para o modelo de dados de entrada adicional, temos duas opções:

  1. Se o modelo de dados adicionais e o modelo atual estiverem carregados em contextos OpenCL diferentes, então, em qualquer caso, teremos que recarregar os dados. Copiamos os dados do respectivo camada do modelo para um novo buffer e criamos um buffer no contexto necessário.
  2. Ambos os modelos estão no mesmo contexto OpenCL. Os dados já existem na memória do contexto. Apenas precisamos copiar o ponteiro para o buffer de resultados da camada neural desejada.

Após obter o buffer com dados de entrada adicionais, voltamos ao modelo de dados de entrada principal. Como mencionado anteriormente, verificamos se os modelos estão carregados na memória do mesmo contexto OpenCL. Se não estiverem, simplesmente copiamos os dados de entrada para o buffer e chamamos o método de propagação desenvolvido anteriormente.

   if(inputNet.opencl != opencl)
     {
      CBufferFloat *inputs;
      if(!inputNet.GetLayerOutput(inputLayer, inputs))
        {
         if(del_second)
            delete second;
         return false;
        }
      bool result = feedForward(inputs, 1, false, second);
      if(del_second)
         delete second;
      return result;
     }

Se ambas as modelos estiverem no mesmo contexto OpenCL, realizamos uma troca de camadas de dados de entrada pela camada neural especificada no modelo de dados de entrada principal.

   CLayer *layer = inputNet.layers.At(inputLayer);
   if(!layer)
     {
      if(del_second)
         delete second;
      return false;
     }
   CNeuronBaseOCL *neuron = layer.At(0);
   layer = layers.At(0);
   if(!layer)
     {
      if(del_second)
         delete second;
      return false;
     }
   if(layer.At(0) != neuron)
      if(!layer.Update(0, neuron))
        {
         if(del_second)
            delete second;
         return false;
        }

Em seguida, organizamos um loop para percorrer todas as camadas neurais, seguido pela chamada dos métodos de propagação.

   for(int l = 1; l < layers.Total(); l++)
     {
      layer = layers.At(l);
      neuron = layer.At(0);
      layer = layers.At(l - 1);
      if(!neuron.FeedForward(layer.At(0), second))
        {
         if(del_second)
            delete second;
         return false;
        }
     }
//---
   if(del_second)
      delete second;
   return true;
  }

Após a conclusão das iterações do loop, saímos do método com um resultado positivo.

Da mesma forma, criaremos o método de retropropagação CNet::backProp. Você pode consultar o código dele no anexo.

Ambos esses métodos serão usados no treinamento do Crítico. No entanto, para treinar o Ator, precisaremos de mais um método de retropropagação. A questão é que, no método de retropropagação, antes de passar o gradiente de erro pelas camadas neurais, primeiro determinávamos a discrepância entre os resultados da propagação e os valores alvo. O método DDPG exclui esse processo para o Ator. E, para a implementação prática desse algoritmo, foi criado o método CNet::backPropGradient.

Nos parâmetros do método, passamos ponteiros para 2 buffers de dados: dados de entrada adicionais e gradiente de erro associado a eles. Ambos os buffers têm valores padrão, o que permite executar o método sem especificar parâmetros.

bool CNet::backPropGradient(CBufferFloat *SecondInput = NULL, CBufferFloat *SecondGradient = NULL)
  {
   if(
! layers || 
! opencl)
      return false;
   CLayer *currentLayer = layers.At(layers.Total() - 1);
   CNeuronBaseOCL *neuron = NULL;
   if(CheckPointer(currentLayer) == POINTER_INVALID)
      return false;

No corpo do método, primeiro verificamos a validade dos ponteiros para os objetos do array dinâmico de camadas neurais e o contexto OpenCL. Também declaramos as variáveis locais necessárias.

Em seguida, organizamos um loop para distribuir o gradiente de erro por todas as camadas neurais do modelo.

//--- Calc Hidden Gradients
   int total = layers.Total();
   for(int layerNum = total - 2; layerNum >= 0; layerNum--)
     {
      CLayer *nextLayer = currentLayer;
      currentLayer = layers.At(layerNum);
      if(CheckPointer(currentLayer) == POINTER_INVALID)
         return false;
      neuron = currentLayer.At(0);
      if(!neuron || !neuron.calcHiddenGradients(nextLayer.At(0), SecondInput, SecondGradient))
         return false;
     }

Observe que, ao organizar o processo, partimos do pressuposto de que o gradiente de erro já está no buffer da última camada neural. Isso está previsto pelo algoritmo DDPG (gradiente de erro do Crítico em relação às ações do Agente). Não há nenhum controle sobre a presença do gradiente de erro, e o uso deste método é de responsabilidade do usuário.

Após a distribuição do gradiente de erro, atualizamos as matrizes de pesos.

   CLayer *prevLayer = layers.At(total - 1);
   for(int layerNum = total - 1; layerNum > 0; layerNum--)
     {
      currentLayer = prevLayer;
      prevLayer = layers.At(layerNum - 1);
      neuron = currentLayer.At(0);
      if(!neuron.UpdateInputWeights(prevLayer.At(0), SecondInput))
         return false;
     }

Aqui, é importante lembrar que, nos métodos das camadas neurais, apenas enfileiramos kernels para execução. Mas, antes de executar a propagação subsequente, precisamos ter certeza de que a operação de retropropagação foi concluída com sucesso. Para garantir isso, carregamos os resultados do último kernel de atualização de matriz de pesos.

   bool result=false;
   for(int layerNum = 0; layerNum < total; layerNum++)
     {
      currentLayer = layers.At(layerNum);
      CNeuronBaseOCL *temp = currentLayer.At(0);
      if(!temp)
        continue; 
      if(!temp.TrainMode() || !temp.getWeights())
         continue;
      if(!temp.getWeights().BufferRead())
         continue;
      result=true;
      break;
     }
//---
   return result;
  }

Com isso, concluímos o trabalho na atualização dos métodos e classes de nossa biblioteca. Você pode consultar o código completo deles no anexo.

2.3. Criando um EA de treinamento do modelo

A seguir, vamos criar e treinar um modelo usando o algoritmo DDPG. A organização do processo de treinamento é implementada no EA "DDPG\Study.mq5".

Como mencionado anteriormente, o modelo que estamos criando combinará elementos do DDPG e abordagens discutidas anteriormente. Isso se refletirá na arquitetura do nosso modelo. Para descrever a arquitetura, criaremos a função CreateDescriptions.

Nos parâmetros da função, ela recebe ponteiros para 2 arrays dinâmicos para armazenar descrições de camadas neurais do Ator e do Crítico. No corpo da função, verificamos a validade dos ponteiros fornecidos e, se necessário, criamos novos objetos para os arrays.

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

Começaremos descrevendo a arquitetura do Ator. Aqui, usaremos as contribuições do GCRL e construiremos um modelo com 2 fluxos de dados de entrada. A base para a tomada de decisões do Ator será o estado atual do ambiente (dados históricos). Para eles, criaremos uma camada de dados de entrada do tamanho correspondente.

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

Os dados "brutos" passam por uma camada de normalização em lote e seguem através de um bloco de camadas convolucionais.

//--- 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 = 8;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   prev_count = descr.count = prev_count;
   descr.window = 8;
   descr.step = 8;
   descr.window_out = 8;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

Em seguida, compactamos os dados em 2 camadas totalmente conectadas. Tudo isso pode lembrar o Codificador usado anteriormente.

//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 256;
   descr.optimization = ADAM;
   descr.activation = LReLU;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 5
   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;
     }

As avaliações da situação de mercado podem ser suficientes para determinar a direção das negociações e os níveis de stop-loss e take-profit. No entanto, não são suficientes para as funções de gerenciamento de capital. Nesta fase, semelhante à definição do problema do modelo, adicionaremos informações sobre o estado da conta.

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

Lembre-se do identificador desta camada e do tamanho do vetor de seus resultados. A partir desta camada, obteremos a representação latente do estado do ambiente como dados de entrada para o Crítico.

Em seguida, há um bloco de tomada de decisão a partir das camadas totalmente conectadas.

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

Na saída do Ator, temos uma camada totalmente conectada com 6 elementos, que representam o tamanho da negociação, seu stop-loss e take-profit (3 elementos para compra e 3 para venda).

//--- layer 9
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 6;
   descr.optimization = ADAM;
   descr.activation = LReLU;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

Em uma forma simplificada, não adicionamos elementos para fechar posições e aguardar um ponto de entrada/saída adequado. Supomos que o fechamento de posições será realizado pelo stop-loss ou take-profit. E a emissão de valores incorretos em um dos indicadores de negociação significa a ausência de uma operação de negociação.

O modelo do Crítico para prever a recompensa utiliza o estado atual do ambiente e a ação do Ator. Em nosso caso, ambas as correntes de informações vêm do modelo do Ator, mas de camadas neurais diferentes. E, consequentemente, de buffers de dados diferentes. Para combinar as duas correntes de dados, usaremos uma camada neural de concatenação de dados. Na arquitetura do modelo do Crítico, isso será refletido da seguinte forma. A primeira corrente de informações (representação latente do estado atual) será enviada para a camada de dados de entrada. O tamanho desta camada deve corresponder ao tamanho da camada neural do Ator da qual pretendemos extrair dados.

//--- Critic
   critic.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = 256;
   descr.window = 0;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!critic.Add(descr))
     {
      delete descr;
      return false;
     }

Os dados vêm do estado interno de outro modelo, e podemos ignorar a camada de normalização de dados.

Em seguida, usamos uma camada de concatenação para combinar as duas correntes de informações. O tamanho dos dados adicionais corresponde ao tamanho da camada de resultados do Ator.

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

Em seguida, há um bloco de tomada de decisão a partir de 2 camadas totalmente conectadas.

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 128;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!critic.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 128;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!critic.Add(descr))
     {
      delete descr;
      return false;
     }

Na saída do Crítico, usamos uma camada totalmente conectada com 1 elemento sem uma função de ativação. Aqui, esperamos obter um valor de recompensa previsto.

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

E para evitar confusões futuras com o identificador da camada de representação latente do estado do ambiente, vamos definir uma constante como uma macro substituição.

#define                    LatentLayer  6

Agora que definimos a arquitetura dos modelos, vamos trabalhar no algoritmo do EA. Primeiro, criaremos o método de inicialização OnInit. No início do método, como fizemos antes, inicializamos objetos de indicadores e operações de negociação.

//+------------------------------------------------------------------+
//| Expert initialization function                                   |
//+------------------------------------------------------------------+
int OnInit()
  {
//---
   if(!Symb.Name(_Symbol))
      return INIT_FAILED;
   Symb.Refresh();
//---
   if(!RSI.Create(Symb.Name(), TimeFrame, RSIPeriod, RSIPrice))
      return INIT_FAILED;
//---
   if(!CCI.Create(Symb.Name(), TimeFrame, CCIPeriod, CCIPrice))
      return INIT_FAILED;
//---
   if(!ATR.Create(Symb.Name(), TimeFrame, ATRPeriod))
      return INIT_FAILED;
//---
   if(!MACD.Create(Symb.Name(), TimeFrame, FastPeriod, SlowPeriod, SignalPeriod, MACDPrice))
      return INIT_FAILED;
   if(!RSI.BufferResize(HistoryBars) || !CCI.BufferResize(HistoryBars) ||
      !ATR.BufferResize(HistoryBars) || !MACD.BufferResize(HistoryBars))
     {
      PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
      return INIT_FAILED;
     }
//---
   if(!Trade.SetTypeFillingBySymbol(Symb.Name()))
      return INIT_FAILED;

Em seguida, tentamos carregar modelos pré-treinados. Se eles não existirem, iniciamos o processo de criação dos modelos.

Aqui, vale a pena observar um detalhe. Se antes criávamos um modelo treinável e o copiávamos completamente para o modelo alvo, agora estamos inicializando modelos treináveis e alvo com parâmetros aleatórios. Ambos os modelos usam a mesma arquitetura.

//--- load models
   float temp;
   if(!Actor.Load(FileName + "Act.nnw", temp, temp, temp, dtStudied, true) ||
      !Critic.Load(FileName + "Crt.nnw", temp, temp, temp, dtStudied, true) ||
      !TargetActor.Load(FileName + "Act.nnw", temp, temp, temp, dtStudied, true) ||
      !TargetCritic.Load(FileName + "Crt.nnw", temp, temp, temp, dtStudied, true))
     {
      CArrayObj *actor = new CArrayObj();
      CArrayObj *critic = new CArrayObj();
      if(!CreateDescriptions(actor, critic))
        {
         delete actor;
         delete critic;
         return INIT_FAILED;
        }
      if(!Actor.Create(actor) || !Critic.Create(critic) ||
         !TargetActor.Create(actor) || !TargetCritic.Create(critic))
        {
         delete actor;
         delete critic;
         return INIT_FAILED;
        }
      delete actor;
      delete critic;
      //---
     }

Em seguida, moveremos todos os modelos para um único contexto OpenCL, o que nos permitirá operar com ponteiros para buffers de dados sem copiar fisicamente informações entre os modelos.

   COpenCLMy *opencl = Actor.GetOpenCL();
   Critic.SetOpenCL(opencl);
   TargetActor.SetOpenCL(opencl);
   TargetCritic.SetOpenCL(opencl);

Depois disso, segue um bloco de verificação da correspondência das arquiteturas dos modelos.

   Actor.getResults(Result);
   if(Result.Total() != 6)
     {
      PrintFormat("The scope of the actor does not match the actions count (%d <> %d)", 6, Result.Total());
      return INIT_FAILED;
     }
   ActorResult = vector<float>::Zeros(6);
//---
   Actor.GetLayerOutput(0, Result);
   if(Result.Total() != (HistoryBars * BarDescr))
     {
      PrintFormat("Input size of Actor doesn't match state description (%d <> %d)", Result.Total(), (HistoryBars * BarDescr));
      return INIT_FAILED;
     }
//---
   Actor.GetLayerOutput(LatentLayer, Result);
   int latent_state = Result.Total();
   Critic.GetLayerOutput(0, Result);
   if(Result.Total() != latent_state)
     {
      PrintFormat("Input size of Critic doesn't match latent state Actor (%d <> %d)", Result.Total(), latent_state);
      return INIT_FAILED;
     }

Inicializamos variáveis globais e finalizamos o método.

   PrevBalance = AccountInfoDouble(ACCOUNT_BALANCE);
   PrevEquity = AccountInfoDouble(ACCOUNT_EQUITY);
   FirstBar = true;
   Gradient.BufferInit(AccountDescr, 0);
   Gradient.BufferCreate(opencl);
//---
   return(INIT_SUCCEEDED);
  }

Determinamos que a atualização dos modelos alvo ocorrerá após cada episódio. Portanto, essa funcionalidade foi incorporada ao método de desativação do EA. Primeiro, atualizamos os modelos alvo. Em seguida, salvamos esses modelos. Observe que estamos salvando os modelos alvo, não os treináveis. Dessa forma, pretendemos minimizar o excesso de treinamento do modelo para um episódio específico.

//+------------------------------------------------------------------+
//| Expert deinitialization function                                 |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
//---
   TargetActor.WeightsUpdate(GetPointer(Actor), Tau);
   TargetCritic.WeightsUpdate(GetPointer(Critic), Tau);
   TargetActor.Save(FileName + "Act.nnw", Actor.getRecentAverageError(), 0, 0, TimeCurrent(), true);
   TargetCritic.Save(FileName + "Crt.nnw", Critic.getRecentAverageError(), 0, 0, TimeCurrent(), true);
   delete Result;
  }

O processo real de treinamento do modelo ocorre na função OnTick. Neste caso, treinaremos o modelo no testador de estratégias no modo de histórico. Não criaremos um buffer de reprodução de experiência. O próprio testador de estratégias desempenhará esse papel. Assim, todo o processo de treinamento é organizado na função OnTick.

No início da função, verificamos se ocorreu um evento de abertura de uma nova vela. Em seguida, atualizamos os dados dos indicadores e os dados históricos dos movimentos de preços do instrumento nos buffers.

//+------------------------------------------------------------------+
//| Expert tick function                                             |
//+------------------------------------------------------------------+
void OnTick()
  {
//---
   if(!IsNewBar())
      return;
//---
   int bars = CopyRates(Symb.Name(), TimeFrame, iTime(Symb.Name(), TimeFrame, 1), HistoryBars, Rates);
   if(!ArraySetAsSeries(Rates, true))
      return;
//---
   RSI.Refresh();
   CCI.Refresh();
   ATR.Refresh();
   MACD.Refresh();
   Symb.Refresh();
   Symb.RefreshRates();

O processo de preparação de dados foi totalmente transferido dos EAs anteriormente considerados, e vou pular a descrição dele. Você pode se familiarizar com o código completo do EA e todas as suas funções no anexo.

Após a preparação dos dados iniciais, verificamos se um propagação foi realizado anteriormente pelo modelo treinável. Se isso tiver ocorrido, realizamos um retropropagação. Para avaliar o estado atual, realizamos um propagação pelo modelo alvo. É importante observar que, inicialmente, realizamos um propagação pelo modelo alvo do Ator. Somente após isso, com base na ação formada, fazemos um propagação pelo modelo alvo do Crítico. Adicionamos o valor real da recompensa do sistema, que é a mudança no saldo da conta, a esse resultado. Além disso, no caso de não haver posições abertas, aplicamos uma penalidade para estimular o Ator a negociar ativamente. Em seguida, executamos o retropropagação, começando pelo Crítico e depois pelo Ator.

   if(!FirstBar)
     {
      if(!TargetActor.feedForward(GetPointer(State), 1, false, GetPointer(Account)))
         return;
      if(!TargetCritic.feedForward(GetPointer(TargetActor), LatentLayer, GetPointer(TargetActor)))
         return;
      TargetCritic.getResults(Result);
      float reward = (float)(account[0] - PrevBalance + Result[0]);
      if(account[0] == PrevBalance)
         if((buy_value + sell_value) == 0)
            reward -= 1;
      Result.Update(0, reward);
      if(!Critic.backProp(Result, GetPointer(Actor)) || !Actor.backPropGradient(GetPointer(PrevAccount), GetPointer(Gradient)))
         return;
     }

Observe que, para o retropropagação do Crítico, usamos o método atualizado backProp, passando o buffer de valores alvo e um ponteiro para o modelo do Ator. Nesse caso, não especificamos o identificador da camada latente, pois já realizamos a substituição de objetos durante o propagação.

Já para o retropropagação do Ator, usamos o método backPropGradient, no qual o gradiente do retropropagação do Crítico é propagado pelo modelo.

A execução do retropropagação do Crítico e do Ator nos permite otimizar a função Q da nossa modelo.

Em seguida, realizamos uma propagação pelo modelo treinável.

   if(!Actor.feedForward(GetPointer(State), 1, false, GetPointer(Account)))
      return;
   if(!Critic.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor)))
      return;

É importante observar que, durante o processo de treinamento da função Q, estamos melhorando a qualidade da previsão da recompensa esperada, mas não estamos treinando o Ator para aumentar o rendimento de suas ações. Para esse propósito, o algoritmo DDPG prevê a atualização dos parâmetros do Ator na direção do aumento da recompensa prevista. Vale ressaltar que, neste momento, estamos passando o gradiente de erro pelo Crítico, mas não atualizando seus parâmetros. Portanto, desativamos a atualização das matrizes de pesos do Crítico, definindo a flag TrainMode como false. Após o retropropagação do Ator, restauramos o valor da flag para true.

   if(!FirstBar)
     {
      Critic.getResults(Result);
      Result.Update(0, Result.At(0) + MathAbs(Result.At(0) * 0.0001f));
      Critic.TrainMode(false);
      if(!Critic.backProp(Result, GetPointer(Actor)) || !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient)))
         return;
      Critic.TrainMode(true);
     }

Armazenamos nas variáveis globais os valores para as operações na próxima barra.

   FirstBar = false;
   PrevAccount.AssignArray(GetPointer(Account));
   PrevAccount.BufferCreate(Actor.GetOpenCL());
   PrevBalance = account[0];
   PrevEquity = account[1];

Em seguida, resta-nos decodificar os resultados do trabalho do Ator e realizar as operações de negociação. Neste exemplo, estamos treinando o Ator para fornecer valores absolutos para o volume da operação e níveis de negociação. Apenas normalizamos os dados e os convertemos em valores de preço específicos.

   vector<float> temp;
   Actor.getResults(temp);
   float delta = MathAbs(ActorResult - temp).Sum();
   ActorResult = temp;
//---
   double min_lot = Symb.LotsMin();
   double stops = MathMax(Symb.StopsLevel(), 1) * Symb.Point();
   double buy_lot = MathRound((double)ActorResult[0] / min_lot) * min_lot;
   double sell_lot = MathRound((double)ActorResult[3] / min_lot) * min_lot;
   double buy_tp = NormalizeDouble(Symb.Ask() + ActorResult[1], Symb.Digits());
   double buy_sl = NormalizeDouble(Symb.Ask() - ActorResult[2], Symb.Digits());
   double sell_tp = NormalizeDouble(Symb.Bid() - ActorResult[4], Symb.Digits());
   double sell_sl = NormalizeDouble(Symb.Bid() + ActorResult[5], Symb.Digits());
//---
   if(ActorResult[0] > min_lot && ActorResult[1] > stops && ActorResult[2] > stops && buy_sl > 0)
      Trade.Buy(buy_lot, Symb.Name(), Symb.Ask(), buy_sl, buy_tp);
   if(ActorResult[3] > min_lot && ActorResult[4] > stops && ActorResult[5] > stops && sell_tp > 0)
      Trade.Sell(sell_lot, Symb.Name(), Symb.Bid(), sell_sl, sell_tp);

Lembramos que não previmos uma ação separada do Ator para aguardar uma situação adequada. Em vez disso, usamos valores incorretos para os parâmetros da operação. Por isso, antes de enviar a solicitação de negociação, verificamos a validade dos parâmetros recebidos.

É importante notar mais um ponto que não foi abordado no algoritmo discutido, mas que foi adicionado por mim. Isso não contradiz o método discutido, mas introduz algumas restrições na política de treinamento do Ator. Isso significa que eu queria impor algumas limitações no tamanho da posição aberta e nos níveis de negociação.

Quando parâmetros de negociação incorretos ou superestimados eram recebidos, eu criava um vetor de valores alvo aleatórios dentro dos limites especificados e realizava um retropropagação no Ator da mesma forma que nos métodos de aprendizado supervisionado. Na minha opinião, isso deveria trazer os resultados do Ator de volta aos limites especificados.

   if(temp.Min() < 0 || MathMax(temp[0], temp[3]) > 1.0f || MathMax(temp[1], temp[4]) > (Symb.Point() * 5000) ||
      MathMax(temp[2], temp[5]) > (Symb.Point() * 2000))
     {
      temp[0] = (float)(Symb.LotsMin() * (1 + MathRand() / 32767.0 * 5));
      temp[3] = (float)(Symb.LotsMin() * (1 + MathRand() / 32767.0 * 5));
      temp[1] = (float)(Symb.Point() * (MathRand() / 32767.0 * 500.0 + Symb.StopsLevel()));
      temp[4] = (float)(Symb.Point() * (MathRand() / 32767.0 * 500.0 + Symb.StopsLevel()));
      temp[2] = (float)(Symb.Point() * (MathRand() / 32767.0 * 200.0 + Symb.StopsLevel()));
      temp[5] = (float)(Symb.Point() * (MathRand() / 32767.0 * 200.0 + Symb.StopsLevel()));
      Result.AssignArray(temp);
      Actor.backProp(Result, GetPointer(PrevAccount), GetPointer(Gradient));
     }
  }

É claro que, como alternativa, poderíamos ter usado uma função de ativação limitadora (por exemplo, a sigmoidal). No entanto, isso teria restringido rigidamente o intervalo de valores possíveis e, durante o treinamento, poderíamos ter chegado rapidamente aos valores limite, o que atrasaria ainda mais o treinamento do modelo.

Após a conclusão de todas as operações, entramos no modo de espera pelo próximo tick.

O código completo do EA e todos os programas usados no artigo estão disponíveis no anexo.


3. Teste

Após concluir o trabalho no EA de treinamento do modelo, passamos à etapa de verificar os resultados do trabalho realizado. Como antes, o treinamento do modelo foi realizado em dados históricos do instrumento EURUSD, período de tempo H1, a partir do início de 2023. Todos os parâmetros dos indicadores e do treinamento do modelo utilizaram os valores padrão.

de modeloTreinamento

O treinamento em tempo real introduz suas próprias nuances e não permite o uso de vários agentes paralelos. Portanto, as primeiras verificações da correção do funcionamento do algoritmo do EA foram realizadas em modo de execução única. Em seguida, o modo de otimização lenta foi selecionado e apenas um agente de otimização local foi ativado.

Com o objetivo de regular o número de iterações de treinamento, um parâmetro externo denominado "Agent" foi adicionado, que não está envolvido no algoritmo do EA.

Gerenciando o número de passagens de otimização

Após cerca de 3000 passagens, consegui obter um modelo capaz de gerar lucro no conjunto de treinamento. Durante o período de treinamento de 5 meses, o modelo realizou 334 negociações, sendo mais de 84% delas lucrativas. Como resultado, um lucro de 33% foi alcançado em relação ao capital inicial. Além disso, a redução no saldo foi inferior a 1%, e a redução no equilíbrio foi de 7,6%. O fator de lucro ultrapassou 26, e o fator de recuperação foi de 3,16. O gráfico abaixo mostra uma tendência de aumento no saldo. Além disso, a linha de saldo quase sempre fica abaixo da linha de equilíbrio, o que indica a abertura de posições na direção correta. Ao mesmo tempo, a carga no depósito é de cerca de 20%, o que é um valor relativamente alto, mas não excede o lucro acumulado.

Resultados de treinamento de modelo

Resultados de treinamento de modelo

Infelizmente, fora do conjunto de treinamento, os resultados do EA foram mais modestos.


Considerações finais

Neste artigo, exploramos a aplicação do aprendizado por reforço no contexto de um espaço de ações contínuo e nos familiarizamos com o método Deep Deterministic Policy Gradient (DDPG). Esta abordagem abre novas possibilidades para treinar um agente a gerenciar capital e riscos, o que é um aspecto crucial para o sucesso nas negociações.

Desenvolvemos e testamos um EA para treinar o modelo. Ele não apenas prevê a direção das negociações, mas também determina o tamanho da posição, os níveis de stop-loss e take-profit. Isso permite que o Agente gerencie investimentos de forma mais eficaz.

Durante o teste, conseguimos treinar o modelo para obter lucro no conjunto de treinamento. No entanto, infelizmente, o treinamento realizado não foi suficiente para obter resultados semelhantes fora do conjunto de treinamento. O ponto fraco de nossa implementação é o treinamento online do modelo, o que não permite o uso simultâneo de vários agentes para aumentar a exploração do ambiente e reduzir o tempo de treinamento do modelo.

Os resultados obtidos nos permitem ter esperança de que seja possível treinar o modelo para um desempenho estável fora do conjunto de treinamento.


Referências

  • Continuous Control with Deep Reinforcement Learning
  • Redes neurais de maneira fácil (Parte 27): aprendizado Q profundo (DQN)
  • Redes neurais de maneira fácil (Parte 29): algoritmo ator-crítico de vantagem (advantage actor-critic)
  • Redes neurais de maneira fácil (Parte 46): aprendizado por reforço condicionado a metas (GCRL)

  • Programas utilizados no artigo

    # Nome Tipo Descrição
    1 Study.mq5  EA EA de treinamento do agente
    2 Test.mq5 EA EA para teste do modelo
    3 NeuroNet.mqh Biblioteca de classe Biblioteca das classes para a criação de uma rede neural
    4 NeuroNet.cl Biblioteca Biblioteca do código do programa OpenCL


    Traduzido do russo pela MetaQuotes Ltd.
    Artigo original: https://www.mql5.com/ru/articles/12853

    Arquivos anexados |
    MQL5.zip (305.57 KB)
    Iniciando o VPS MetaTrader pela primeira vez - Instruções passo a passo Iniciando o VPS MetaTrader pela primeira vez - Instruções passo a passo
    Para todos que usam Expert Advisors ou assinaturas de sinais, mais cedo ou mais tarde, será necessário um serviço de hospedagem confiável 24 horas por dia para a plataforma de negociação. Recomendamos o uso do VPS MetaTrader por vários motivos. Você pode pagar e gerenciar o serviço através da sua conta na MQL5.community.
    DoEasy. Controles (Parte 32): "ScrollBar" horizontal, rolagem com a roda do mouse DoEasy. Controles (Parte 32): "ScrollBar" horizontal, rolagem com a roda do mouse
    Neste artigo, concluiremos o desenvolvimento do funcional do objeto de barra de rolagem horizontal. Vamos habilitar a capacidade de rolar o conteúdo do contêiner movendo o controle deslizante da barra de rolagem e girando a roda do mouse. Além disso, faremos adições à biblioteca para acomodar a nova política de execução de ordens e os novos códigos de erro de tempo de execução no MQL5.
    Desenvolvendo um sistema de Replay (Parte 31): Projeto Expert Advisor - Classe C_Mouse (V) Desenvolvendo um sistema de Replay (Parte 31): Projeto Expert Advisor - Classe C_Mouse (V)
    Desenvolver uma forma de colocar o cronometro, de modo que durante um replay / simulação, ele consiga nos dizer quanto tempo falta, pode parecer a principio uma tarefa simples e de rápida solução. Muitos iriam simplesmente tentar adaptar e usar o mesmo sistema que é usado quando temos o servidor de negociação ao nosso lado. Mas aqui mora um ponto que muitos talvez não se atentem ao pensar em tal solução. Quando você está fazendo um replay, e isto para não falar do fato da simulação, o relógio não funciona da mesma forma. Este tipo de coisa torna complexo construir tal sistema.
    Entendendo a programação orientada a objetos (POO) em MQL5 Entendendo a programação orientada a objetos (POO) em MQL5
    Como desenvolvedores, precisamos aprender a criar e desenvolver software que possa ser usado de forma repetida e flexível, sem duplicação de código, especialmente quando lidamos com diferentes objetos que têm comportamentos distintos. Isso pode ser facilmente alcançado usando métodos e princípios de programação orientada a objetos. Neste artigo, apresentaremos os fundamentos da programação orientada a objetos em MQL5.