English Русский 中文 Español Deutsch 日本語
preview
Redes neurais no trading: Integração da teoria do caos à previsão de séries temporais (Conclusão)

Redes neurais no trading: Integração da teoria do caos à previsão de séries temporais (Conclusão)

MetaTrader 5Sistemas de negociação |
19 0
Dmitriy Gizlyk
Dmitriy Gizlyk

Introdução

Damos continuidade ao desenvolvimento de nossa própria interpretação das abordagens propostas pelos autores do framework Attraos. No artigo anterior, vimos os aspectos teóricos do framework. Vale lembrar que ele utiliza princípios da teoria do caos para resolver tarefas de previsão de séries temporais.

A arquitetura do framework Attraos é um sistema complexo composto por vários componentes, que combina métodos de análise não linear, machine learning e otimização computacional. O uso do método de reconstrução do espaço de fases (Phase Space Reconstruction - PSR) permite ao Attraos modelar processos dinâmicos ocultos e considerar relações não lineares entre diferentes variáveis de mercado. Isso possibilita identificar estruturas estáveis nos dados de mercado e utilizá-las para melhorar a qualidade da previsão dos próximos movimentos de preço.

Um dos principais componentes do Attraos é a unidade de memória dinâmica multirresolução (Multi-Resolution Dynamic Memory Unit - MDMU), que permite ao modelo preservar padrões históricos dos movimentos de preço e se adaptar às mudanças nas condições de mercado. Isso é especialmente importante nos mercados financeiros, onde os padrões podem se repetir em diferentes intervalos de tempo, com amplitudes e intensidades distintas. O modelo se adapta dinamicamente às mudanças na estrutura dos mercados financeiros, produzindo previsões mais precisas em diferentes horizontes temporais.

A aplicação da estratégia de evolução local no domínio da frequência permite adaptar o modelo às mudanças nas condições de mercado, acentuando as diferenças entre os atratores. Isso ajuda o modelo a minimizar erros e manter sob controle os desvios dos atratores, garantindo estabilidade e alta precisão das previsões.

A visualização do framework Attraos proposta pelos autores é apresentada abaixo.

Na parte prática do artigo anterior, implementamos os componentes básicos no programa OpenCL. Hoje passaremos à criação dos objetos no programa principal.


Criação do objeto Attraos

O algoritmo Attraos começa pelo módulo PSR, que transforma a série temporal analisada em um espaço de fases considerando um lag temporal predefinido. Essa transformação constitui uma etapa fundamental do pré-processamento dos dados, pois permite identificar dependências ocultas, a estrutura da série temporal e possíveis padrões latentes em sua dinâmica.

Uma série temporal multidimensional geralmente é representada como uma matriz, em que cada linha contém os parâmetros do sistema analisado em determinado instante t. No entanto, em nosso caso, os dados são armazenados em buffers unidimensionais, e sua representação matricial é apenas conceitual. A estrutura dos dados foi definida de modo que os valores dos vetores que descrevem o estado do sistema em cada instante sejam armazenados sequencialmente no buffer. O tamanho desse vetor é definido pelo parâmetro window. Portanto, para criar subsequências com determinado lag temporal, basta aumentar proporcionalmente o valor de window e, em contrapartida, reduzir o comprimento da sequência. Dessa forma, a transformação da série temporal em espaço de fases não exige recursos computacionais adicionais e é obtida exclusivamente pela escolha da arquitetura do modelo.

Todas as operações subsequentes do framework serão implementadas no objeto CNeuronAttraos, cuja estrutura é apresentada abaixo.

class CNeuronAttraos :  public CNeuronBaseOCL
  {
protected:
   CNeuronBaseOCL    cOne;
   CNeuronBaseOCL    cX_norm;
   CNeuronConvOCL    cA;
   CNeuronConvOCL    cX_proj;
   CNeuronBaseOCL    cDelta;
   CNeuronBaseOCL    cB;
   CNeuronBaseOCL    cC;
   CNeuronConvOCL    cD;
   CNeuronBaseOCL    cH;
   CNeuronConvOCL    cDelta_proj;
   CNeuronBaseOCL    cDeltaA;
   CNeuronBaseOCL    cDeltaB;
   CNeuronBaseOCL    cDeltaBX;
   CNeuronBaseOCL    cDeltaH;
   CNeuronBaseOCL    cHS;
   //---
   virtual bool      PScan(void);
   virtual bool      PScanCalcGradient(void);
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL) override;

public:
                     CNeuronAttraos(void) {};
                    ~CNeuronAttraos(void) {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                          uint window, uint window_key, uint units_count,
                          ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual int       Type(void) override   const   {  return defNeuronAttraos; }
   //---
   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;
  };

Na estrutura apresentada para a nova classe, além do conjunto padrão de métodos virtuais sobrescritos, vemos um número considerável de objetos internos. Eles desempenham funções distintas e permitem a interação entre os elementos da classe. O uso desses objetos internos ajuda a estruturar o código de maneira mais eficiente. Cada um é responsável por uma parte específica da funcionalidade, tornando o sistema modular e facilitando a introdução de alterações. Durante a implementação dos métodos do novo objeto, examinaremos com mais detalhes a funcionalidade de cada componente interno. Isso permitirá compreender sua finalidade e avaliar seu papel na estrutura geral.

Todos os objetos internos são declarados estaticamente, o que dispensa sua criação e liberação dinâmicas. Por isso, o construtor e o destrutor da classe permanecem vazios, já que a memória desses objetos é gerenciada automaticamente. A inicialização de todos os objetos declarados e herdados é feita no método Init. Em seus parâmetros, recebemos constantes que permitem definir de forma inequívoca a arquitetura do objeto a ser criado. Acredito que, neste caso, a estrutura desses parâmetros não gere dúvidas.

bool CNeuronAttraos::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                       uint window, uint window_key, uint units_count,
                       ENUM_OPTIMIZATION optimization_type, uint batch)
  {
   if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, window * units_count, optimization_type, batch))
      return false;
   SetActivationFunction(None);

No corpo do método, como de costume, começamos chamando o método de mesmo nome da classe pai, no qual já estão implementados os pontos mínimos de controle necessários e a inicialização das interfaces herdadas.

Aqui também desativamos explicitamente a função de ativação do nosso objeto, pois todo o processamento é executado pelos objetos internos. As interfaces herdadas são utilizadas apenas para a troca de dados em nível global.

Após a execução bem-sucedida das operações do método da classe pai, passamos à inicialização dos objetos declarados. Primeiro, inicializamos os objetos correspondentes a duas matrizes de parâmetros treináveis:

  • A - matriz de transição de estados;
  • D - matriz de conexões residuais com os dados originais.

Como essas matrizes serão multiplicadas por matrizes completas que contêm todos os elementos da sequência analisada, precisamos replicar seus valores desde o início de acordo com o número de elementos da sequência. Isso evita operações adicionais de cópia e otimiza a retropropagação do gradiente do erro.

Como antes, para estruturar os parâmetros treináveis, utilizamos um pequeno modelo formado por duas camadas consecutivas. A primeira camada contém um valor fixo, enquanto a segunda gera o tensor necessário multiplicando seus parâmetros internos treináveis pelo valor fixo da primeira camada. Essa abordagem permite aproveitar os algoritmos já implementados nas camadas neurais para treinar os parâmetros, sem necessidade de implementar funcionalidades adicionais. Para reduzir ao mínimo a quantidade de parâmetros treináveis da segunda camada, normalmente utilizamos apenas um elemento na primeira camada.

Neste caso, porém, precisamos obter na saída da segunda camada um tensor com valores repetidos. Para isso, repetimos o valor fixo na primeira camada a quantidade de vezes especificada. Como segunda camada, utilizaremos uma camada convolucional cujo número de filtros será igual à quantidade de parâmetros treináveis. O tamanho da janela e o passo da convolução serão definidos como 1, de modo que cada elemento do tensor de saída dependa apenas de um único valor de entrada.

   int index = 0;
   if(!cOne.Init(0, index, OpenCL, units_count, optimization, iBatch))
      return false;
   if(!cOne.getOutput().Fill(1))
      return false;
   cOne.SetActivationFunction(None);
//---
   index++;
   if(!cA.Init(0, index, OpenCL, 1, 1, window * window_key, units_count, 1, optimization, iBatch))
      return false;
   cA.SetActivationFunction(MinusSoftPlus);
   CBufferFloat *w = cA.GetWeightsConv();
   if(!w || !w.Fill(0))
      return false;

Como a primeira camada não possui parâmetros treináveis neste caso, podemos utilizá-la também para gerar a segunda matriz de parâmetros treináveis. Portanto, em seguida inicializamos apenas o segundo objeto responsável pela geração dos parâmetros treináveis necessários.

   index++;
   if(!cD.Init(0, index, OpenCL, 1, 1, window, units_count, 1, optimization, iBatch))
      return false;
   cD.SetActivationFunction(None);
   w = cD.GetWeightsConv();
   if(!w || !w.Fill(1))
      return false;

Observe que, durante a inicialização dos objetos, preenchemos as matrizes de parâmetros treináveis com valores fixos. Isso difere um pouco da abordagem mais comum, em que os parâmetros treináveis são inicializados com valores aleatórios. A inicialização fixa é útil quando o modelo precisa preservar determinadas propriedades nas etapas iniciais do treinamento ou quando as condições iniciais exercem influência significativa sobre a distribuição final dos parâmetros. Neste caso, ela permite evitar flutuações bruscas no início do treinamento e favorece uma adaptação mais gradual do modelo aos dados.

Os demais parâmetros do modelo de espaço de estado serão definidos em função dos dados de entrada, o que permitirá adaptá-los às características específicas da sequência analisada. Para gerá-los, utilizaremos uma camada convolucional que produz de uma só vez os valores de todos os componentes do modelo. Essa abordagem proporciona um processamento eficiente dos dados, pois a convolução é executada em paralelo ao longo de toda a sequência, acelerando significativamente os cálculos.

Antes de gerar os parâmetros do modelo de espaço de estado, porém, normalizamos os dados de entrada. A normalização elimina o efeito das diferenças de escala entre os valores originais, tornando a otimização mais estável e previsível.

//---
   index++;
   if(!cX_norm.Init(0, index, OpenCL, window * units_count, optimization, iBatch))
      return false;
   cX_norm.SetActivationFunction(None);
   index++;
   if(!cX_proj.Init(0, index, OpenCL, window, window, 4 * window_key, units_count, 1, optimization, iBatch))
      return false;
   cX_proj.SetActivationFunction(None);

Em seguida, precisaremos separar os parâmetros gerados do modelo em componentes individuais. Para armazenar esses dados, criaremos objetos adicionais cujos nomes indicarão o tipo de informação armazenada.

   index++;
   if(!cDelta.Init(0, index, OpenCL, window_key * units_count, optimization, iBatch))
      return false;
   cDelta.SetActivationFunction(None);
   index++;
   if(!cB.Init(0, index, OpenCL, window_key * units_count, optimization, iBatch))
      return false;
   cB.SetActivationFunction(None);
   index++;
   if(!cC.Init(0, index, OpenCL, window_key * units_count, optimization, iBatch))
      return false;
   cC.SetActivationFunction(None);
   index++;
   if(!cH.Init(0, index, OpenCL, window_key * units_count, optimization, iBatch))
      return false;
   cH.SetActivationFunction(None);

Na etapa seguinte, inicializamos o objeto responsável pela geração dos parâmetros de decaimento exponencial dos estados ocultos. Esse componente desempenha um papel fundamental no controle da dinâmica das informações propagadas ao longo da sequência, regulando o quanto os estados anteriores são preservados ou atenuados.

   index++;
   if(!cDelta_proj.Init(0, index, OpenCL, window_key, window_key, window, units_count, 1, optimization, iBatch))
      return false;
   cDelta_proj.SetActivationFunction(SoftPlus);

O uso da SoftPlus como função de ativação garante que a saída contenha apenas valores positivos.

Em seguida, inicializamos mais alguns objetos destinados a armazenar resultados intermediários dos cálculos. Todos eles têm o mesmo tamanho.

   index++;
   if(!cDeltaA.Init(0, index, OpenCL, window * window_key * units_count, optimization, iBatch))
      return false;
   cDeltaA.SetActivationFunction(None);
   index++;
   if(!cDeltaB.Init(0, index, OpenCL, window * window_key * units_count, optimization, iBatch))
      return false;
   cDeltaB.SetActivationFunction(None);
   index++;
   if(!cDeltaBX.Init(0, index, OpenCL, window * window_key * units_count, optimization, iBatch))
      return false;
   cDeltaBX.SetActivationFunction(None);
   index++;
   if(!cDeltaH.Init(0, index, OpenCL, window * window_key * units_count, optimization, iBatch))
      return false;
   cDeltaH.SetActivationFunction(None);
   index++;
   if(!cHS.Init(0, index, OpenCL, window * window_key * units_count, optimization, iBatch))
      return false;
   cHS.SetActivationFunction(None);
//---
   return true;
  }

Concluímos a execução do método de inicialização retornando ao programa chamador o resultado lógico das operações realizadas.

Observe que, neste objeto, não armazenamos os parâmetros da arquitetura em variáveis locais separadas. Nesta implementação, optamos por não criar variáveis adicionais para manter permanentemente valores que já estão armazenados nos objetos internos. Em vez disso, utilizaremos variáveis locais, preenchidas no início dos métodos de propagação para frente e propagação reversa.

Concluída a inicialização do objeto, passamos à implementação do algoritmo de propagação para frente no método feedForward. Em seus parâmetros, recebemos um ponteiro para o objeto que contém os dados de entrada.

bool CNeuronAttraos::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
//---
   uint window = cX_proj.GetWindow();
   uint window_key = cX_proj.GetFilters() / 4;
   uint units = cD.GetUnits();

No corpo do método, primeiro recuperamos dos objetos internos os parâmetros dos dados de entrada que não foram armazenados durante a inicialização. Em seguida, geramos os tensores dos parâmetros treináveis do modelo.

   if(!cA.FeedForward(cOne.AsObject()))   // (Units, Window, WindowKey)
      return false;
   if(!cD.FeedForward(cOne.AsObject()))   // (Units, Window))
      return false;

Na etapa seguinte, normalizamos os dados de entrada e geramos os parâmetros contextuais do modelo.

   if(!NeuronOCL ||
      !SumAndNormilize(NeuronOCL.getOutput(), NeuronOCL.getOutput(), cX_norm.getOutput(), window, true, 0, 0, 0, 0.5f))
      return false;
   if(!cX_proj.FeedForward(cX_norm.AsObject()))    // (Units, 4WindowKey)
      return false;

Depois, separamos esses parâmetros em componentes individuais.

   if(!DeConcat(cDelta.getOutput(), cB.getOutput(), cC.getOutput(), cH.getOutput(), cX_proj.getOutput(),
                window_key, window_key, window_key, window_key, units))   // 4*(Units, WindowKey)
      return false;

Em seguida, geramos os parâmetros do passo temporal adaptativo.

   if(!cDelta_proj.FeedForward(cDelta.AsObject()))       // (Units, Window)
      return false;

Com isso, concluímos a etapa preparatória e passamos à implementação do algoritmo MDMU, responsável pela modelagem da dinâmica da série temporal. O estado do modelo é atualizado de acordo com a seguinte equação recorrente:

onde Δt é o passo temporal adaptativo.

Primeiro, calculamos o valor correspondente ao primeiro termo. Para isso, porém, substituímos a função exponencial pela SoftPlus, que apresenta algumas vantagens.

   if(!DiagMatMul(cDelta_proj.getOutput(), cA.getOutput(), cDeltaA.getOutput(),
                  window, window_key, units, SoftPlus))  // (Units, Window, WindowKey)
      return false;

A SoftPlus cresce mais lentamente do que a exponencial, reduzindo o risco de crescimento abrupto da matriz de transição. Com isso, os gradientes variam de forma mais suave e o treinamento se torna mais estável.

A função exponencial altera fortemente a dinâmica mesmo diante de pequenas variações de Δ. A SoftPlus suaviza essas mudanças, reduzindo a probabilidade de saltos bruscos nos estados ocultos.

Em dados ruidosos, a SoftPlus também reduz a influência de valores atípicos. Seu crescimento é limitado por uma função logarítmica, o que torna o modelo mais robusto.

Em seguida, calculamos os valores do segundo termo por meio de multiplicações matriciais sucessivas.

   if(!MatMul(cDelta_proj.getOutput(), cB.getOutput(), cDeltaB.getOutput(),
              window, 1, window_key, units))             // (Units, Window, WindowKey)
      return false;
   if(!DiagMatMul(cX_norm.getOutput(), cDeltaB.getOutput(), cDeltaBX.getOutput(),
                  window, window_key, units, None))      // (Units, Window, WindowKey)
      return false;

Depois, ajustamos a matriz do regulador dinâmico das alterações dos estados ocultos de acordo com a taxa de variação do estado oculto.

   if(!MatMul(cDelta_proj.getOutput(), cH.getOutput(), cDeltaH.getOutput(),
              window, 1, window_key, units))             // (Units, Window, WindowKey)
      return false;

Com todos os dados necessários preparados, atualizamos os estados ocultos do sistema utilizando o algoritmo de varredura paralela que implementamos no artigo anterior em OpenCL. Neste caso, basta chamar o método wrapper do kernel PScan.

if(!PScan())
   return false;

O método responsável pela chamada do kernel segue o algoritmo padrão, por isso não entraremos em seus detalhes neste artigo. O código completo desse método está disponível no anexo do artigo, no arquivo NeuroNet.cl.

Em seguida, resta gerar o estado previsto do sistema analisado. Para isso, multiplicamos a matriz do estado oculto atualizado pela matriz de projeção dos estados ocultos.

if(!MatMul(cHS.getOutput(), cC.getOutput(), Output, window, window_key, 1, units)) // (Units, Window, 1)
   return false;

Multiplicamos os dados normalizados do estado de entrada pelos coeficientes das conexões diretas.

if(!ElementMult(cD.getOutput(), cX_norm.getOutput(), PrevOutput))           // (Units, Window))
   return false;

Depois, somamos os resultados das duas operações.

if(!SumAndNormilize(Output, PrevOutput, Output, window, false, 0, 0, 0, 1))   // (Units, Window))
   return false;

Por fim, adicionamos também os dados de entrada, formando o caminho residual principal.

   if(!SumAndNormilize(Output, NeuronOCL.getOutput(), Output, window, false, 0, 0, 0, 1))   // (Units, Window))
      return false;
//---
   return true;
  }

Essa abordagem permite combinar as informações dos estados ocultos com as dependências de curto prazo presentes nos dados de entrada.

Com isso, concluímos o algoritmo de propagação para frente de nossa interpretação do framework Attraos. Retornamos ao programa chamador o resultado lógico das operações realizadas e encerramos a execução do método.

Na etapa seguinte, passamos à implementação dos algoritmos de propagação reversa do nosso objeto. Neste artigo, examinaremos o método calcInputGradients, responsável pela distribuição dos gradientes do erro. Como antes, seus parâmetros recebem um ponteiro para o objeto que contém os dados de entrada. Desta vez, porém, precisamos propagar para esse objeto o gradiente do erro de acordo com a influência dos dados de entrada sobre o resultado final do modelo.

bool CNeuronAttraos::calcInputGradients(CNeuronBaseOCL *NeuronOCL)
  {
   if(!NeuronOCL)
      return false;

No corpo do método, começamos verificando se o ponteiro recebido é válido, pois só podemos transferir os dados para um objeto válido. Caso contrário, todas as operações subsequentes perdem o sentido.

Em seguida, assim como no método de propagação para frente, armazenamos em variáveis locais os parâmetros dos dados de entrada.

uint window = cX_proj.GetWindow();
uint window_key = cX_proj.GetFilters() / 4;
uint units = cD.GetUnits();

Depois, distribuímos o gradiente do erro proveniente do nível de saída entre os respectivos fluxos de informação. Vale lembrar que, durante a propagação para frente, utilizamos três fluxos de dados:

  • modelo de espaço de estado;
  • conexões diretas com coeficientes;
  • conexões residuais.

Primeiro, distribuímos o gradiente do erro entre os coeficientes das conexões diretas e os dados de entrada normalizados.

if(!ElementMultGrad(cD.getOutput(), cD.getGradient(), cX_norm.getOutput(), cX_norm.getPrevOutput(),
                    Gradient, cD.Activation(), None))          // (Units, Window))
   return false;

Em seguida, propagamos o gradiente do erro pelo segundo fluxo de informação, distribuindo o gradiente do erro entre os estados ocultos do sistema e seus coeficientes de projeção.

if(!MatMulGrad(cHS.getOutput(), cHS.getGradient(), cC.getOutput(), cC.getGradient(), Gradient,
               window, window_key, 1, units)) // (Units, Window, 1)
   return false;

Quando necessário, ajustamos os resultados obtidos de acordo com as derivadas das respectivas funções de ativação.

if(cHS.Activation() != None)
  {
   if(!DeActivation(cHS.getOutput(), cHS.getGradient(), cHS.getGradient(), cHS.Activation()))
      return false;
  }
if(cC.Activation() != None)
  {
   if(!DeActivation(cC.getOutput(), cC.getGradient(), cC.getGradient(), cC.Activation()))
      return false;
  }

Depois, propagamos o gradiente do erro pelo módulo de varredura paralela, chamando o método wrapper do kernel correspondente.

if(!PScanCalcGradient())
   return false;

Os valores obtidos são então distribuídos entre os respectivos componentes. Primeiro, propagamos o gradiente do erro para os estados ocultos e para os parâmetros do passo temporal adaptativo.

if(!MatMulGrad(cDelta_proj.getOutput(), cDelta_proj.getGradient(), cH.getOutput(), cH.getGradient(),
               cDeltaH.getGradient(), window, 1, window_key, units))             // (Units, Window, WindowKey)
   return false;

Em seguida, propagamos o gradiente do erro até o nível dos dados de entrada normalizados.

if(!DiagMatMulGrad(cX_norm.getOutput(), cX_norm.getGradient(), cDeltaB.getOutput(), cDeltaB.getGradient(),
                   cDeltaBX.getGradient(), window, window_key, units))      // (Units, Window, WindowKey)
   return false;
if(!SumAndNormilize(cX_norm.getGradient(), cX_norm.getPrevOutput(), cX_norm.getPrevOutput(),
                                                                  window, false, 0, 0, 0, 1))
   return false;

Aqui é importante observar que já havíamos propagado anteriormente valores do gradiente do erro para o objeto que armazena os dados de entrada normalizados. Portanto, nesta etapa, somamos os valores provenientes dos dois fluxos de informação.

Também distribuímos o gradiente do erro entre os coeficientes que determinam a influência dos dados de entrada sobre o estado oculto e os parâmetros do passo temporal adaptativo.

if(!MatMulGrad(cDelta_proj.getOutput(), cDelta_proj.getPrevOutput(), cB.getOutput(), cB.getGradient(),
               cDeltaB.getGradient(), window, 1, window_key, units))             // (Units, Window, WindowKey)
   return false;
if(!SumAndNormilize(cDelta_proj.getGradient(), cDelta_proj.getPrevOutput(), cDelta_proj.getGradient(),
                    window, false, 0, 0, 0, 1))
   return false;

No nível dos parâmetros do passo temporal adaptativo, somamos esses valores aos gradientes já acumulados.

Em seguida, precisamos propagar o gradiente do erro até o nível da matriz de evolução do estado oculto. Antes disso, porém, ajustamos os valores obtidos de acordo com a derivada da função de ativação.

if(!DeActivation(cDeltaA.getOutput(), cDeltaA.getGradient(), cDeltaA.getGradient(), SoftPlus))
   return false;

Depois, distribuímos os valores entre os respectivos componentes.

if(!DiagMatMulGrad(cDelta_proj.getOutput(), cDelta_proj.getPrevOutput(), cA.getOutput(), cA.getGradient(),
                   cDeltaA.getGradient(), window, window_key, units))  // (Units, Window, WindowKey)
   return false;
if(!SumAndNormilize(cDelta_proj.getGradient(), cDelta_proj.getPrevOutput(), cDelta_proj.getGradient(),
                    window, false, 0, 0, 0, 1))
   return false;

Nesta etapa, voltamos a acumular os valores do gradiente do erro no nível dos parâmetros do passo temporal adaptativo. Desta vez, porém, trata-se do último fluxo de informação nessa direção. Por isso, ajustamos os valores acumulados de acordo com a derivada da função de ativação correspondente.

if(cDelta_proj.Activation() != None)
  {
   if(!DeActivation(cDelta_proj.getOutput(), cDelta_proj.getGradient(), cDelta_proj.getGradient(),
                                                                        cDelta_proj.Activation()))
      return false;
  }

Em seguida, propagamos o gradiente do erro até o nível dos passos temporais adaptativos.

if(!cDelta.calcHiddenGradients(cDelta_proj.AsObject()))
   return false;

Agora já obtivemos os gradientes do erro no nível de todos os componentes dependentes do contexto. Reunimos os valores obtidos em um único tensor.

if(!Concat(cDelta.getGradient(), cB.getGradient(), cC.getGradient(), cH.getGradient(), cX_proj.getGradient(),
           window_key, window_key, window_key, window_key, units))   // 4*(Units, WindowKey)
   return false;

Depois, propagamos o gradiente do erro até o nível dos dados de entrada normalizados.

if(!cX_norm.calcHiddenGradients(cX_proj.AsObject()))
   return false;
if(!SumAndNormilize(cX_norm.getGradient(), cX_norm.getPrevOutput(), cX_norm.getGradient(),
                                                               window, false, 0, 0, 0, 1))
   return false;

Vale lembrar que já havíamos propagado duas vezes o gradiente do erro para o objeto que armazena os dados de entrada normalizados. Portanto, os valores obtidos nesta etapa são somados aos que já estavam acumulados.

A eles também adicionamos os valores provenientes do caminho residual principal. Em seguida, propagamos os valores acumulados até o nível dos dados de entrada, ajustando-os previamente de acordo com a derivada da função de ativação correspondente.

   if(!SumAndNormilize(cX_norm.getGradient(), Gradient, cX_norm.getGradient(), window, false, 0, 0, 0, 1))
      return false;
   if(!DeActivation(NeuronOCL.getOutput(), NeuronOCL.getGradient(), cX_norm.getGradient(), NeuronOCL.Activation()))
      return false;
//---
   return true;
  }

Com isso, concluímos a execução do método de distribuição do gradiente do erro e retornamos ao programa chamador o resultado lógico das operações realizadas.

Quanto ao método updateInputWeights, responsável pela atualização dos parâmetros do modelo, proponho que você o examine por conta própria. Nele, apenas chamamos os métodos de mesmo nome dos quatro objetos internos que contêm parâmetros treináveis.

Aqui também vale comentar brevemente os algoritmos dos métodos de salvamento e restauração do objeto. Nossa nova classe contém um número considerável de objetos internos, mas apenas 4 deles possuem parâmetros treináveis. Portanto, ao salvar o objeto, basta gravar em disco as informações referentes a esses quatro objetos internos.

bool CNeuronAttraos::Save(const int file_handle)
  {
   if(!CNeuronBaseOCL::Save(file_handle))
      return false;
//---
   if(!cA.Save(file_handle))
      return false;
   if(!cD.Save(file_handle))
      return false;
   if(!cX_proj.Save(file_handle))
      return false;
   if(!cDelta_proj.Save(file_handle))
      return false;
//---
   return true;
  }

Surge, porém, a questão de como restaurar o estado operacional do objeto. No método Load, primeiro carregamos do disco os dados salvos anteriormente.

bool CNeuronAttraos::Load(const int file_handle)
  {
   if(!CNeuronBaseOCL::Load(file_handle))
      return false;
//---
   if(!LoadInsideLayer(file_handle, cA.AsObject()))
      return false;
   if(!LoadInsideLayer(file_handle, cD.AsObject()))
      return false;
   if(!LoadInsideLayer(file_handle, cX_proj.AsObject()))
      return false;
   if(!LoadInsideLayer(file_handle, cDelta_proj.AsObject()))
      return false;

Em seguida, armazenamos em variáveis locais os parâmetros da arquitetura do objeto.

   uint window = cX_proj.GetWindow();
   uint window_key = cX_proj.GetFilters() / 4;
   uint units_count = cD.GetUnits();

A partir daí, o algoritmo repete a inicialização dos objetos destinados ao armazenamento temporário dos dados.

   if(!cOne.Init(0, 0, OpenCL, units_count, optimization, iBatch))
      return false;
   if(!cOne.getOutput().Fill(1))
      return false;
   cOne.SetActivationFunction(None);
   int index = 3;
   if(!cX_norm.Init(0, index, OpenCL, window * units_count, optimization, iBatch))
      return false;
   cX_norm.SetActivationFunction(None);
   index += 2;
   if(!cDelta.Init(0, index, OpenCL, window_key * units_count, optimization, iBatch))
      return false;
   cDelta.SetActivationFunction(None);
   index++;
   if(!cB.Init(0, index, OpenCL, window_key * units_count, optimization, iBatch))
      return false;
   cB.SetActivationFunction(None);
   index++;
   if(!cC.Init(0, index, OpenCL, window_key * units_count, optimization, iBatch))
      return false;
   cC.SetActivationFunction(None);
   index++;
   if(!cH.Init(0, index, OpenCL, window_key * units_count, optimization, iBatch))
      return false;
   cH.SetActivationFunction(None);
   index += 2;
   if(!cDeltaA.Init(0, index, OpenCL, window * window_key * units_count, optimization, iBatch))
      return false;
   cDeltaA.SetActivationFunction(None);
   index++;
   if(!cDeltaB.Init(0, index, OpenCL, window * window_key * units_count, optimization, iBatch))
      return false;
   cDeltaB.SetActivationFunction(None);
   index++;
   if(!cDeltaBX.Init(0, index, OpenCL, window * window_key * units_count, optimization, iBatch))
      return false;
   cDeltaBX.SetActivationFunction(None);
   index++;
   if(!cDeltaH.Init(0, index, OpenCL, window * window_key * units_count, optimization, iBatch))
      return false;
   cDeltaH.SetActivationFunction(None);
   index++;
   if(!cHS.Init(0, index, OpenCL, window * window_key * units_count, optimization, iBatch))
      return false;
   cHS.SetActivationFunction(None);
//---
   return true;
  }

Essa abordagem permite otimizar tanto o salvamento dos dados e a restauração do estado operacional do objeto quanto o uso do espaço em disco.

Com isso, concluímos a análise dos algoritmos de implementação do framework Attraos em MQL5. O código completo da classe CNeuronAttraos e de todos os seus métodos está disponível no anexo.



Arquitetura do modelo

Após implementar os algoritmos do framework Attraos, passamos à descrição da arquitetura dos modelos treináveis. Neste experimento, treinamos dois modelos utilizando uma abordagem de aprendizado multitarefa. A arquitetura de ambos é definida no método CreateDescriptions. Em seus parâmetros, recebemos ponteiros para 2 arrays dinâmicos, nos quais devemos armazenar a descrição da arquitetura dos modelos.

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

No corpo do método, começamos verificando se os ponteiros recebidos são válidos e, quando necessário, criamos novas instâncias dos objetos.

Primeiro, descreveremos a arquitetura do ator, que utilizará as abordagens do framework Attraos implementadas acima. Como de costume, o modelo começa com uma camada totalmente conectada para os dados de entrada, seguida por uma camada de normalização em lote. Essa abordagem permite alimentar o modelo diretamente com os dados brutos recebidos do terminal, enquanto o próprio modelo realiza a normalização como etapa inicial de pré-processamento.

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

Em seguida, utilizamos a primeira camada da arquitetura Attraos. Neste caso, para transformar os dados de entrada em espaço de fases, usamos um lag temporal de cinco passos, equivalente a cinco minutos em um timeframe de um minuto.

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronAttraos;
   descr.window = BarDescr*5;    // 5 min
   descr.count = HistoryBars/5;  // 24
   descr.window_out = 256;
   descr.batch = 1e4;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

Na camada seguinte, aumentamos o passo temporal para 15 elementos.

//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronAttraos;
   descr.window = BarDescr*15;    // 15 min
   descr.count = HistoryBars/15;  // 8
   descr.window_out = 256;
   descr.batch = 1e4;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

Na terceira, aumentamos para 30.

//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronAttraos;
   descr.window = BarDescr*30;    // 30 min
   descr.count = HistoryBars/30;  // 4
   descr.window_out = 256;
   descr.batch = 1e4;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

Cabe observar que, na saída de cada objeto CNeuronAttraos, obtemos um resultado com a mesma dimensionalidade dos dados de entrada. Por isso, utilizamos em seguida uma camada convolucional para reduzir a dimensionalidade do tensor por um fator de três.

//--- layer 5
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   prev_count=descr.count = HistoryBars/3;
   descr.window = BarDescr*3;
   descr.step = descr.window;
   int prev_window=descr.window_out = BarDescr;
   descr.activation = SoftPlus;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

Depois dela, vem a cabeça de decisão, composta por três camadas totalmente conectadas consecutivas.

//--- layer 6
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 512;
   descr.batch = 1e4;
   descr.activation = TANH;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 7
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 256;
   descr.activation = TANH;
   descr.batch = 1e4;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 8
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = NActions;
   descr.activation = SoftPlus;
   descr.batch = 1e4;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

Normalizamos o resultado obtido.

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

E adicionamos o bloco de gestão de risco.

//--- layer 10
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronMacroHFTvsRiskManager;
//--- Windows
     {
      int temp[] = {3, 15, NActions, AccountDescr}; //Window, Stack Size, N Actions, Account Description
      if(ArrayCopy(descr.windows, temp) < int(temp.Size()))
         return false;
     }
   descr.count = 10;
   descr.window_out = 16;
   descr.step = 4;                              // Heads
   descr.batch = 1e4;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 11
   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;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

O modelo de probabilidades da direção do próximo movimento foi transferido integralmente dos trabalhos anteriores, sem alterações. Portanto, não apresentaremos sua descrição neste artigo. A descrição completa da arquitetura dos modelos treináveis está disponível no anexo. Lá você também encontrará o código completo dos programas de treinamento dos modelos e de interação com o ambiente, igualmente transferidos dos trabalhos anteriores sem alterações.



Testes

Ao longo de dois artigos, realizamos um trabalho considerável de adaptação e desenvolvimento das ideias propostas pelos autores do framework Attraos. Agora chegamos a uma das etapas fundamentais: avaliar o funcionamento e a eficiência dos métodos implementados com dados históricos reais. Essa etapa é decisiva para determinar a aplicabilidade prática do modelo e sua capacidade de identificar padrões, mantendo resultados estáveis em diferentes condições de mercado.

Para o treinamento do modelo, utilizamos uma amostra composta pelas cotações históricas do par de moedas EURUSD no timeframe M1 durante todo o ano de 2024. Todos os parâmetros dos indicadores analisados permaneceram com seus valores padrão, sem qualquer otimização adicional. Essa abordagem permite eliminar a influência de fatores externos, como o ajuste dos parâmetros a dados históricos específicos, e concentrar a avaliação exclusivamente na qualidade intrínseca do modelo. Também é importante observar que manter inalterados os parâmetros dos indicadores permite avaliar a capacidade do modelo de se adaptar à dinâmica real do mercado sem necessidade de intervenção e reajustes constantes.

Como antes, o treinamento do modelo é realizado em duas etapas. Na primeira, definimos o tamanho do lote de treinamento como 1, permitindo utilizar, a cada iteração, um estado completamente aleatório da amostra de treinamento. Com isso, cada iteração se aproxima ao máximo das condições de processamento de um novo estado. No entanto, isso não é suficiente para treinar corretamente o bloco de gestão de risco. Por isso, na segunda etapa aumentamos o tamanho do lote para 60, o que permite ajustar o modelo e o bloco de gestão de risco com base em 60 estados consecutivos do ambiente. Em um timeframe de um minuto, isso corresponde a uma hora.

Para testar o modelo treinado, utilizamos os dados de janeiro e fevereiro de 2025. Esse período foi escolhido para avaliar rigorosamente a capacidade do modelo de operar com dados novos, nunca vistos anteriormente. Todos os demais parâmetros do experimento permaneceram inalterados, garantindo a consistência do experimento, a reprodutibilidade dos resultados e a validade das comparações posteriores. Essa abordagem reduz a influência de fatores aleatórios e permite avaliar de forma objetiva a qualidade dos algoritmos.

Os resultados dos testes são apresentados abaixo.

Durante o período de teste, o modelo realizou 287 operações de trading, das quais quase 39% foram encerradas com lucro. Apesar da taxa relativamente baixa de operações vencedoras, a estratégia apresentou resultado positivo graças à relação entre ganhos e perdas. Em particular, o lucro médio por operação vencedora foi duas vezes maior que a perda média, o que permitiu compensar as operações menos favoráveis, encerrar o período com resultado financeiro positivo e registrar um fator de lucro de 1,15.

O tempo médio de permanência em posição ultrapassou duas horas, indicando uma tendência do modelo a tomar decisões de trading de curto e médio prazo. No entanto, chama atenção a posição mantida aberta por mais tempo, que permaneceu nessa condição por quase dois dias. Esse comportamento exige uma análise adicional.



Conclusão

Conhecemos o framework Attraos, que utiliza conceitos da teoria do caos para resolver tarefas de previsão de séries temporais. Os autores do framework integram abordagens de análise não linear, reconstrução do espaço de fases, memória dinâmica com diferentes níveis de resolução e algoritmos adaptativos. Essas tecnologias ajudam a produzir previsões mais precisas e a adaptar os modelos de trading às mudanças nas condições de mercado.

Na parte prática, implementamos em MQL5 nossa própria interpretação das abordagens propostas, construímos os modelos e os treinamos com dados históricos reais. Os resultados obtidos ao testar o modelo treinado em dados históricos que não faziam parte da amostra de treinamento indicam que o modelo é capaz de gerar lucro com dados novos. No entanto, os resultados também revelaram alguns problemas. Em particular, observamos que determinadas posições permanecem abertas por períodos prolongados. Além disso, a curva de saldo não é tão suave quanto gostaríamos. Isso indica que o modelo tem potencial, mas ainda precisa de otimização adicional.

Observe que essas conclusões se aplicam apenas à implementação apresentada. A versão original dos autores não foi testada neste trabalho.


Referências


Programas utilizados no artigo

# Nome Tipo Descrição
1 Research.mq5 Expert Advisor EA para coleta de exemplos
2 ResearchRealORL.mq5
Expert Advisor
EA para coleta de exemplos pelo método Real-ORL
3 Study.mq5 Expert Advisor EA para treinamento dos modelos
4 Test.mq5 Expert Advisor EA para teste do modelo
5 Trajectory.mqh Biblioteca de classes Estrutura de descrição do estado do sistema e da arquitetura dos modelos
6 NeuroNet.mqh Biblioteca de classes Biblioteca de classes para criação de redes neurais
7 NeuroNet.cl Biblioteca Biblioteca de código do programa OpenCL

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

Arquivos anexados |
MQL5.zip (2509.49 KB)
Caminhe em novos trilhos: Personalize indicadores no MQL5 Caminhe em novos trilhos: Personalize indicadores no MQL5
Vou agora listar todas as possibilidades novas e recursos do novo terminal e linguagem. Elas são várias, e algumas novidades valem a discussão em um artigo separado. Além disso, não há códigos aqui escritos com programação orientada ao objeto, é um tópico muito importante para ser simplesmente mencionado em um contexto como vantagens adicionais para os desenvolvedores. Neste artigo vamos considerar os indicadores, sua estrutura, desenho, tipos e seus detalhes de programação em comparação com o MQL4. Espero que este artigo seja útil tanto para desenvolvedores iniciantes quanto para experientes, talvez alguns deles encontrem algo novo.
Redes neurais no trading: agregação do movimento ao longo do tempo (Conclusão) Redes neurais no trading: agregação do movimento ao longo do tempo (Conclusão)
Apresentamos o framework TMA, um sistema inteligente capaz de prever a dinâmica do mercado com precisão suficiente. Neste artigo, reunimos todos os componentes em uma única arquitetura e os transformamos em um agente de trading completo, que analisa o mercado e toma decisões em tempo real.
Está chegando o novo MetaTrader 5 e MQL5 Está chegando o novo MetaTrader 5 e MQL5
Esta é apenas uma breve resenha do MetaTrader 5. Eu não posso descrever todos os novos recursos do sistema por um período tão curto de tempo - os testes começaram em 09.09.2009. Esta é uma data simbólica, e tenho certeza que será um número de sorte. Alguns dias passaram-se desde que eu obtive a versão beta do terminal MetaTrader 5 e MQL5. Eu ainda não consegui testar todos os seus recursos, mas já estou impressionado.
Integração rápida de um grande modelo de linguagem com o MetaTrader 5 (Parte I): Criando o modelo Integração rápida de um grande modelo de linguagem com o MetaTrader 5 (Parte I): Criando o modelo
O artigo explora uma integração revolucionária entre grandes modelos de linguagem (LLM) e a plataforma de trading MetaTrader 5, na qual a AI não se limita a prever preços, mas toma decisões de negociação de forma autônoma, analisando o contexto do mercado como faria um trader experiente. O autor mostra a diferença fundamental entre os LLM e modelos clássicos de machine learning, como o CatBoost: a capacidade de metacognição e autorreflexão, que permite ao sistema aprender com os próprios erros e aprimorar sua estratégia.