Русский Español
preview
Redes neurais em trading: Conjunto de agentes com uso de mecanismos de atenção (MASAAT)

Redes neurais em trading: Conjunto de agentes com uso de mecanismos de atenção (MASAAT)

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

Introdução

A gestão de portfólios de instrumentos financeiros é um aspecto central nas decisões de investimento, com o objetivo de aumentar a rentabilidade minimizando riscos por meio da alocação dinâmica de capital entre os ativos. A alta volatilidade dos mercados financeiros, onde os preços dos ativos dependem de muitos fatores, dificulta a gestão de um portfólio ideal que atenda a dois objetivos conflitantes: maximizar o lucro e minimizar os riscos. Modelos financeiros tradicionais, desenvolvidos com base em diferentes princípios de investimento, muitas vezes são eficazes apenas em um único mercado e podem falhar em condições complexas de mercados dinâmicos.

Recentemente, tem havido um foco crescente na aplicação de métodos de aprendizado de máquina para análise de séries de preços não estacionárias. Entre esses métodos, destacam-se as estratégias de aprendizado profundo e o aprendizado por reforço, que têm mostrado resultados significativos nas finanças computacionais. No entanto, os dados de preços nos mercados financeiros geralmente consistem em séries temporais ruidosas, nas quais é difícil extrair informações que indiquem tendências futuras.

Uma possível solução para esses problemas é apresentada no trabalho "Developing an attention-based ensemble learning framework for financial portfolio optimisation". Seus autores propuseram uma estrutura inovadora de trading adaptativo com mecanismos de atenção e análise de séries temporais integrados (Multi-Agente e Auto-Adaptativa com mecanismos de Atenção e Séries Temporaisintegrados— MASAAT). Dentro desse framework, é criado um conjunto de agentes para observação e análise de mudanças direcionais nos preços dos ativos em diferentes níveis de detalhamento, com o objetivo de revisar cuidadosamente os portfólios para equilibrar a rentabilidade geral e os riscos de investimento em mercados financeiros altamente voláteis.

Com o uso de filtros de movimento direcional, que aplicam diferentes valores de limiar para detectar mudanças significativas nos preços, os agentes inicialmente extraem características de tendências a partir de séries temporais de preços comuns, buscando acompanhar as transições dos estados de mercado sob diferentes perspectivas. Essa abordagem oferece uma nova forma de gerar tokens na sequência, permitindo que o módulo de análise cruzada baseado em atenção (CSA) e o módulo de análise temporal (TA) dos agentes, criados dentro da estrutura proposta, possam capturar de forma eficaz as correlações entre ativos e as dependências entre os pontos no tempo. Especificamente, na reconstrução dos mapas de características, o token de sequência no módulo CSA é baseado nas características dos ativos individuais, focando na otimização da incorporação das estimativas de atenção entre ativos, enquanto o token de sequência no módulo TA é baseado nas peculiaridades de pontos temporais individuais, tentando destacar a relevância entre pontos temporais atuais e anteriores.

Além disso, as informações sobre dependências entre ativos e pontos temporais são combinadas em um bloco de atenção espaço-temporal. Graças à separação clara das funcionalidades entre os módulos CSA e TA, os agentes recebem mais dados para realizar uma análise contínua das tendências dos ativos e propor portfólios conforme suas perspectivas específicas. Por fim, os portfólios sugeridos por diferentes agentes são reunidos em um novo portfólio por conjunto para reagir rapidamente às condições atuais do mercado. Mesmo que um agente específico não consiga avaliar corretamente as tendências do mercado e produza sugestões tendenciosas, a estrutura MASAAT, ao integrar diversos agentes, ainda é capaz de ajustar adaptativamente o portfólio final para reduzir impactos negativos.


Algoritmo MASAAT

O framework MASAAT aplica vários filtros para detecção de movimento direcional com diferentes valores de limiar para capturar mudanças significativas nos preços dos ativos a partir de campos receptivos multiescalares, com o objetivo de analisar possíveis influências sobre os movimentos futuros dos preços. Especificamente, os campos receptivos representam diferentes níveis de variação nos preços dos ativos, o que dá aos agentes a capacidade de perceber de forma intuitiva os estados dinâmicos do mercado usando diversos filtros. Além disso, ao reconstruir funções orientadas para ativos com base no movimento direcional no módulo CSA e tendências orientadas para os pontos temporais no módulo TA sob a forma de tokens de sequência, o esquema multiagente MASAAT consegue coletar simultaneamente informações espaciais e temporais em diferentes níveis de variação de preço. Isso auxilia na identificação da direção e da magnitude das tendências futuras. Da mesma forma, os dados brutos das séries de preços serão diretamente transformados em características de preços orientadas para os ativos e para os pontos temporais, com a extração subsequente das informações cruzadas e temporais pelos módulos CSA e TA.

Vale destacar que os módulos CSA e TA são baseados em codificadores com mecanismos Self-Attention, nos quais as estimativas de atenção são calculadas sobre a sequência global de todos os tokens, de forma que a medição da similaridade entre todos os ativos possa ser calculada de maneira justa. Em contraste, uma rede neural baseada em convolução é altamente sensível à posição relativa dos ativos nos mapas de características, concentrando-se em regiões locais conforme o tamanho do kernel de convolução. Por outro lado, graças às estimativas de atenção que indicam a semelhança entre os tokens, os sinais de trading gerados pela estrutura proposta podem ser mais interpretáveis. Posteriormente, utilizando o mecanismo de atenção no bloco espaço-temporal para construir o mapeamento entre a sequência de ativos e a sequência de pontos temporais históricos, os agentes de trading geram incorporações que representam as estimativas de atenção de cada ativo para cada ponto no tempo dentro de uma janela de observação específica, e então propõem suas versões dos portfólios de investimento. O gerador de portfólios resume todas as sugestões de diferentes agentes para criar um novo portfólio revisado, com o objetivo de se adaptar ao ambiente financeiro atual.

Seja N o número de ativos no portfólio, M o número de características observadas nos mercados financeiros, e Ma o número de agentes de trading. Para uma profundidade histórica definida, o agente inicialmente observa as características de preço 𝐏 ∈ RN×M×Tw durante o período de observação Tw. Em seguida, funções baseadas em tendências 𝐏DC={𝐏DC,1, 𝐏DC,2,…,𝐏DC,𝐌a} ∈ RMa, 𝐏DC,i ∈ RN×M×Tw são obtidas com a aplicação dos filtros de movimento direcional. Como mencionado anteriormente, o método 𝐏DC,i será transformado em 𝐏DC,i,CSA ∈ RN×MTw para o módulo CSA e 𝐏DC,i,TA ∈ RTw×NM para o módulo TA, seguido da análise de interdependências no codificador Transformer. Da mesma forma, a série original de preços 𝐏 é convertida em 𝐏CSA ∈ RN×MTw e 𝐏TA ∈ RTw×NM.

Após a análise das dependências entre tokens na sequência fornecida, os módulos CSA e TA retornam incorporações orientadas para os ativos 𝐎CSA ∈ RN×D e para os pontos no tempo 𝐎TA ∈ RTD, onde D representa o tamanho do vetor de uma única incorporação. Posteriormente, essas incorporações são combinadas para criar um novo portfólio e, em seguida, integradas com os resultados dos outros agentes para obter o vetor final de dependências W𝐭 e ajustar o portfólio.

Após a execução das operações de trading, a recompensa rt será coletada e armazenada no buffer de replay de experiências Ď, juntamente com W𝐭, 𝐏 e 𝐏DC. Além disso, a política do Ator π será atualizada iterativamente à medida que o buffer de replay Ď for explorado com o uso do método de gradiente de política.

Como lucros mais elevados geralmente vêm acompanhados de maiores riscos, a diversificação dos riscos de investimento é uma tarefa importante, porém complexa, na qual os agentes de trading devem atribuir pesos adequados a ativos de naturezas distintas para realizar hedge. Assim, o estudo contínuo das correlações entre ativos ajuda os agentes a gerenciar melhor os riscos em condições de alta turbulência.

As funções de tendência brutas serão transformadas para gerar os tokens de sequência apropriados antes de analisar as correlações entre os ativos usando codificadores baseados em Self-Attention. O vetor de atenção otimizado mede a correlação entre dois ativos distintos, em que dois ativos com vetores de atenção semelhantes indicam propriedades mais relevantes.

Além da análise da correlação entre dois ativos, o framework MASAAT também tenta investigar a relevância dos pontos temporais dentro de um determinado período de observação para prever a tendência dos preços em diferentes níveis. A análise temporal considera o ponto no tempo como um token na sequência, a fim de estudar as correlações entre os pontos no tempo usando codificadores Transformer. Consequentemente, dois modelos de tendência de pontos temporais são considerados semelhantes quando seus vetores de atenção estão próximos.

Após coletar as informações dos módulos CSA e TA, os agentes do MASAAT combinam as estimativas dos ativos e dos pontos no tempo usando um mecanismo de atenção, buscando obter estimativas de atenção de cada ativo para cada ponto no tempo ao longo do período de observação. O resultado, na forma do portfólio proposto por cada agente, pode ser representado da seguinte maneira:

onde 𝐕i e bi são os parâmetros aprendidos do MLP.

Os resultados de cada agente de trading, considerando diferentes níveis de detalhamento nas variações de preços, serão combinados para criar um novo portfólio, com o objetivo de reagir ao mercado financeiro atual. Em comparação com o portfólio gerado por um único agente, os múltiplos agentes do MASAAT oferecem diversos portfólios potenciais de acordo com as observações das características do mercado sob diferentes pontos de vista. Isso pode ampliar a capacidade do sistema para lidar com diversos mercados financeiros, especialmente em momentos de alta volatilidade.

A visualização original do framework MASAAT é apresentada abaixo.




Implementação com MQL5

Após a análise dos aspectos teóricos do framework MASAAT, passamos agora para a parte prática do nosso artigo, na qual exploraremos uma possível implementação da nossa visão dos métodos apresentados, utilizando os recursos do MQL5. Como você pode ter percebido, o MASAAT é um framework complexo. Para uma separação clara das funcionalidades entre os blocos individuais, vamos construir uma estrutura em blocos no formato de objetos separados, onde cada um será responsável por uma parte da funcionalidade do MASAAT.

Começaremos com a construção do mecanismo de detecção de tendências. Vale destacar que a tarefa de identificar tendências locais pode ser muito bem executada por uma camada de representação por partes lineares da série temporal. No entanto, há um detalhe: o objeto que construímos anteriormente pode representar apenas um único agente. Para implementar o framework MASAAT, precisamos fornecer ao usuário uma funcionalidade flexível para criar modelos com diferentes quantidades de agentes.

Naturalmente, poderíamos criar um array dinâmico contendo ponteiros para diversos objetos de representação por partes lineares da série temporal analisada, cada um com diferentes valores de limiar para mudanças nas características. Mas esse método leva a uma execução sequencial, o que não é a opção mais eficiente. Por isso, criaremos um novo objeto no qual organizaremos a execução paralela dos agentes de detecção de tendências. Mas, antes disso, precisamos criar os respectivos kernels no lado do programa OpenCL.

Complemento do programa OpenCL


Ao tentar modernizar os kernels existentes de representação por partes lineares das séries temporais, deparamos com a necessidade de substituir o valor discreto do limiar de mudança da característica por um vetor de valores, contendo os níveis de limiar para cada agente. Essa modificação exigirá não apenas alterações no algoritmo do kernel, mas também uma revisão completa da estrutura dos objetos que interagem com ele. Por isso, decidimos criar novos kernels para a propagação para frente e a propagação reversa, com partes do algoritmo reaproveitadas dos já existentes.

Para organizar a propagação para frente, criamos o kernel PLRMultiAgents. Nos parâmetros do kernel, recebemos 4 ponteiros para buffers de dados. Desses, 2 buffers contêm os valores brutos na forma da série temporal analisada e os limiares de mudança das características para cada agente. Nos outros dois, gravaremos os resultados da análise e os indicadores de presença de extremos.

__kernel void PLRMultiAgents(__global const float *inputs,
                             __global float *outputs,
                             __global int *isttp,
                             const int transpose,
                             __global const float *min_step
                            )
  {
   const size_t i = get_global_id(0);
   const size_t lenth = get_global_size(0);
   const size_t v = get_global_id(1);
   const size_t variables = get_global_size(1);
   const size_t a = get_global_id(2);
   const size_t agents = get_global_size(2);

Pretendemos executar esse kernel em um espaço de tarefas tridimensional. O primeiro eixo corresponde ao tamanho da sequência analisada. O segundo eixo determina a quantidade de séries unitárias na sequência multimodal. E o terceiro corresponde ao número de agentes. No corpo do kernel, identificamos imediatamente o fluxo atual em todos os eixos do espaço de tarefas. Em seguida, determinamos o deslocamento nos buffers de dados.

//--- constants
   const int shift_in = ((bool)transpose ? (i * variables + v) : (v * lenth + i));
   const int step_in = ((bool)transpose ? variables : 1);
   const int shift_ag = a * lenth * variables;

Aqui é importante observar que todos os agentes analisam uma única sequência multimodal. Portanto, o identificador do agente afeta apenas o deslocamento nos buffers de resultados e nos valores do limiar de desvio de preço.

Após uma breve preparação, passamos para a detecção de extremos. Cada thread determina a existência de um ponto de reversão de tendência na posição do elemento atual. Os pontos extremos da série temporal analisada recebem automaticamente o status de ponto de reversão de tendência, pois são, a priori, os limites do segmento.

//--- look for ttp
   float value = IsNaNOrInf(inputs[shift_in], 0);
   bool bttp = false;
   if(i == 0 || i == lenth - 1)
      bttp = true;

Nos demais casos, buscamos primeiro o desvio mais próximo dos valores da série analisada, anterior ao elemento atual, que atenda ao valor mínimo necessário dentro da sequência unitária analisada. Durante esse processo, armazenamos os valores mínimos e máximos dentro do intervalo verificado.

   else
     {
      float prev = value;
      int prev_pos = i;
      float max_v = value;
      float max_pos = i;
      float min_v = value;
      float min_pos = i;
      while(fmax(fabs(prev - max_v), fabs(prev - min_v)) < min_step[a] && prev_pos > 0)
        {
         prev_pos--;
         prev = IsNaNOrInf(inputs[shift_in - (i - prev_pos) * step_in], 0);
         if(prev >= max_v && (prev - min_v) < min_step[a])
           {
            max_v = prev;
            max_pos = prev_pos;
           }
         if(prev <= min_v && (max_v - prev) < min_step[a])
           {
            min_v = prev;
            min_pos = prev_pos;
           }
        }

Depois, de maneira semelhante, buscamos o elemento seguinte mais próximo que atenda ao desvio mínimo necessário.

      float next = value;
      int next_pos = i;
      while(fmax(fabs(next - max_v), fabs(next - min_v)) < min_step[a] && next_pos < (lenth - 1))
        {
         next_pos++;
         next = IsNaNOrInf(inputs[shift_in + (next_pos - i) * step_in], 0);
         if(next > max_v && (next - min_v) < min_step[a])
           {
            max_v = next;
            max_pos = next_pos;
           }
         if(next < min_v && (max_v - next) < min_step[a])
           {
            min_v = next;
            min_pos = next_pos;
           }
        }

Em seguida, verificamos se há um extremo na posição analisada.

      if(
         (value >= prev && value > next) ||
         (value > prev && value == next) ||
         (value <= prev && value < next) ||
         (value < prev && value == next)
      )
         if(max_pos == i || min_pos == i)
            bttp = true;
     }

Observe que, durante a busca por elementos com o desvio mínimo necessário, construímos um tipo de corredor de valores com múltiplos elementos da sequência, que podem formar uma espécie de platô de extremo. Portanto, o elemento recebe o sinalizador de ponto de reversão de tendência somente se ele for o extremo dentro desse corredor. No caso de vários elementos com o mesmo valor, o sinalizador de extremo é atribuído ao primeiro deles.

Armazenamos o sinalizador obtido e limpamos o buffer de resultados. Ao mesmo tempo, sincronizamos os threads do grupo de trabalho.

   isttp[shift_in + shift_ag] = (int)bttp;
   outputs[shift_in + shift_ag] = 0;
   barrier(CLK_LOCAL_MEM_FENCE);

As operações seguintes são realizadas apenas pelos threads nos quais foi identificado um ponto de reversão de tendência. Os demais não satisfazem as condições estabelecidas e, na prática, encerram suas operações.

Primeiro, identificamos a posição do extremo atual. Para isso, contamos o número de extremos com base nos sinalizadores armazenados até a posição analisada e salvamos antecipadamente, em uma variável local, a posição do extremo anterior no buffer de dados brutos.

//--- calc position
   int pos = -1;
   int prev_in = 0;
   int prev_ttp = 0;
   if(bttp)
     {
      pos = 0;
      for(int p = 0; p < i; p++)
        {
         int current_in = ((bool)transpose ? (p * variables + v) : (v * lenth + p));
         if((bool)isttp[current_in + shift_ag])
           {
            pos++;
            prev_ttp = p;
            prev_in = current_in;
           }
        }
     }

Depois, determinamos os parâmetros da aproximação linear da tendência do segmento atual.

//--- cacl tendency
   if(pos > 0 && pos < (lenth / 3))
     {
      float sum_x = 0;
      float sum_y = 0;
      float sum_xy = 0;
      float sum_xx = 0;
      int dist = i - prev_ttp;
      for(int p = 0; p < dist; p++)
        {
         float x = (float)(p);
         float y = IsNaNOrInf(inputs[prev_in + p * step_in], 0);
         sum_x += x;
         sum_y += y;
         sum_xy += x * y;
         sum_xx += x * x;
        }
      float slope = IsNaNOrInf((dist * sum_xy - sum_x * sum_y) / (dist > 1 ? (dist * sum_xx - sum_x * sum_x) : 1), 0);
      float intercept = IsNaNOrInf((sum_y - slope * sum_x) / dist, 0);

Em seguida, armazenamos os valores obtidos no buffer de resultados.

      int shift_out = ((bool)transpose ? ((pos - 1) * 3 * variables + v) : (v * lenth + (pos - 1) * 3)) + shift_ag;
      outputs[shift_out] = slope;
      outputs[shift_out + step_in] = intercept;
      outputs[shift_out + 2 * step_in] = ((float)dist) / lenth;
     }

Lembrando que cada segmento obtido é caracterizado por 3 parâmetros:

  • slope — inclinação da linha de tendência;
  • intercept — deslocamento da linha de tendência no subespaço dos dados brutos;
  • dist — comprimento do segmento.

Armazenar o comprimento do segmento como um valor inteiro, neste caso, não é a melhor opção. Para que o modelo funcione de forma eficaz, é desejável um formato normalizado na representação dos dados. Por isso, convertemos o tamanho inteiro do segmento em uma fração do comprimento da sequência unitária analisada. Para isso, dividimos o número de elementos no segmento pelo número total de elementos da sequência unitária da série temporal. E para evitar cair na "armadilha das operações inteiras", primeiro convertamos a quantidade de elementos do segmento do tipo int para o tipo float.

Adicionalmente, criaremos uma ramificação separada das operações para o último segmento. Isso porque, neste estágio, não sabemos quantos segmentos serão formados em determinado momento. Hipoteticamente, em caso de grandes variações nos elementos da série temporal e com um valor de limiar muito pequeno, há a possibilidade de surgirem pontos de reversão de tendência em cada elemento da série. Esse cenário é improvável, mas ainda assim não queremos um aumento desnecessário no volume de dados. Ao mesmo tempo, também não desejamos perder dados.

Portanto, partimos do conhecimento prévio sobre a representação de séries temporais no MQL5 e da compreensão da estrutura dos dados analisados, os dados mais recentes estão no início da nossa série temporal. A esses daremos mais atenção. Os dados localizados no final da sequência analisada possuem uma profundidade histórica maior e, possivelmente, exercem menos influência sobre eventos futuros. Ainda assim, não descartaremos tais dependências.

Como consequência, utilizamos para o armazenamento dos resultados de cada agente um buffer de dados com tamanho equivalente ao do tensor de valores brutos da série temporal. Isso nos permite armazenar até três vezes menos segmentos do que o total da sequência (3 elementos para representar 1 segmento). Acreditamos que esse volume seja mais que suficiente. Mesmo assim, por precaução, caso haja um número maior de segmentos, para evitar perda de dados, consolidamos os dados dos últimos segmentos em um só.

   else
     {
      if(pos == (lenth / 3))
        {
         float sum_x = 0;
         float sum_y = 0;
         float sum_xy = 0;
         float sum_xx = 0;
         int dist = lenth - prev_ttp;
         for(int p = 0; p < dist; p++)
           {
            float x = (float)(p);
            float y = IsNaNOrInf(inputs[prev_in + p * step_in], 0);
            sum_x += x;
            sum_y += y;
            sum_xy += x * y;
            sum_xx += x * x;
           }
         float slope = IsNaNOrInf((dist * sum_xy - sum_x * sum_y) / (dist > 1 ? (dist * sum_xx - sum_x * sum_x) : 1),0);
         float intercept = IsNaNOrInf((sum_y - slope * sum_x) / dist, 0);
         int shift_out = ((bool)transpose ? ((pos - 1) * 3 * variables + v) : (v * lenth + (pos - 1) * 3)) + shift_ag;
         outputs[shift_out] = slope;
         outputs[shift_out + step_in] = intercept;
         outputs[shift_out + 2 * step_in] = IsNaNOrInf((float)dist / lenth, 0);
        }
     }
  }

Na maioria dos casos, esperamos que haja um número reduzido de segmentos, e assim os últimos elementos do nosso buffer de resultados permanecerão preenchidos com valores nulos.

Como pode ser observado, no algoritmo de propagação para frente não utilizamos parâmetros treináveis. Por isso, todo o algoritmo de propagação reversa se resume à distribuição do gradiente de erro, que implementamos no kernel PLRMultiAgentsGradient.

Aqui é importante relembrar que todos os agentes analisam uma única série temporal. Portanto, no nível dos dados brutos, precisaremos reunir o gradiente de erro de todos os agentes. Esperamos que seja utilizado um número relativamente pequeno de agentes. Por isso, não complicamos demais a lógica do kernel. Em vez disso, basicamente transferimos o algoritmo já implementado para distribuição do gradiente de erro de um único agente. Apenas adicionamos um parâmetro para indicar o número de agentes e, no corpo do kernel, organizamos um laço para reunir os gradientes de erro de todos os agentes. Recomendo que esse kernel seja estudado de forma independente. O código completo do programa OpenCL pode ser encontrado no anexo.

Objeto do mecanismo de identificação de tendências


Concluída a parte do programa OpenCL, passamos para a nossa biblioteca principal, onde implementamos o algoritmo de detecção de tendências multiagente no objeto CNeuronPLRMultiAgentsOCL. Como você deve ter percebido, estamos basicamente expandindo o objeto de representação por partes lineares da série temporal. Por esse motivo, escolhemos esse objeto como classe base. A estrutura do novo objeto é apresentada a seguir.

class CNeuronPLRMultiAgentsOCL  :  public CNeuronPLROCL
  {
protected:
   int               iAgents;
   CBufferFloat      cMinDistance;
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL);
   //---
   virtual bool      calcInputGradients(CNeuronBaseOCL *prevLayer);

public:
                     CNeuronPLRMultiAgentsOCL(void)  : iAgents(1) {};
                    ~CNeuronPLRMultiAgentsOCL(void) {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                          uint window_in, uint units_count, bool transpose,
                          vector<float> &min_distance,
                          ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual int       Type(void)   const   {  return defNeuronPLRMultiAgentsOCL;   }
   //---
   virtual bool      Save(int const file_handle);
   virtual bool      Load(int const file_handle);
   virtual void      SetOpenCL(COpenCLMy *obj);
  };

Na nova classe, declaramos uma constante representando o número de agentes utilizados (iAgents) e um buffer para armazenar os valores de limiar de mudança das características na série temporal analisada (cMinDistance).

A utilização de declaração estática dos objetos internos nos permite manter os métodos construtor e destrutor da classe vazios. A inicialização de todos os objetos declarados e herdados é realizada no método Init.

bool CNeuronPLRMultiAgentsOCL::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                                    uint window_in, uint units_count, bool transpose,
                                    vector<float> &min_distance,
                                    ENUM_OPTIMIZATION optimization_type, uint batch)
  {
   iAgents = (int)min_distance.Size();
   if(iAgents <= 0)
      return false;

Observe que, nos parâmetros do método, passamos apenas o vetor de valores de limiar. Não indicamos diretamente a quantidade de agentes utilizados. Essa quantidade é determinada com base no tamanho do vetor de limiares recebido. Assim, reduzimos a quantidade de parâmetros externos do método e garantimos a coerência entre o valor do parâmetro e o comprimento do buffer.

No corpo do método, após armazenar a quantidade de agentes em uma variável interna e verificar se o valor é válido (ao menos um agente é necessário para o funcionamento adequado), chamamos o método de inicialização do objeto base, no qual são inicializadas as principais interfaces.

   if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, window_in * units_count * iAgents, optimization_type, batch))
      return false;

Vale ressaltar que utilizamos o método de inicialização do objeto base, e não da classe imediatamente pai. Isso se deve ao fato de aumentarmos o tamanho do buffer de resultados proporcionalmente ao número de agentes. No entanto, agora também será necessário inicializar os objetos herdados.

Primeiro, salvamos os valores dos parâmetros recebidos nas variáveis herdadas.

   iVariables = (int)window_in;
   iCount = (int)units_count;
   bTranspose = transpose;

Em seguida, inicializamos o buffer de sinalizadores de presença de extremos.

   icIsTTP = OpenCL.AddBuffer(sizeof(int) * Neurons(), CL_MEM_READ_WRITE);
   if(icIsTTP < 0)
      return false;

É importante observar que os valores desse buffer de sinalizadores de extremos são redefinidos após cada propagação para frente. O tamanho desse buffer é igual ao do buffer de resultados. Como não há necessidade de preservar os valores contidos nesse buffer, criamos ele apenas na memória do contexto OpenCL. Aqui, apenas armazenamos o ponteiro para o buffer criado.

Em seguida, inicializamos o buffer dos valores de limiar.

   if(!cMinDistance.AssignArray(min_distance) ||
      !cMinDistance.BufferCreate(OpenCL))
      return false;
//---
   return true;
  }

Depois disso, finalizamos a execução do método, retornando o resultado lógico das operações para o programa chamador.

Além disso, sobrescrevemos os métodos de propagação para frente e propagação reversa. No entanto, esses métodos apenas fazem chamadas aos kernels apresentados anteriormente. Como os algoritmos desses métodos não apresentam grandes complexidades, sugiro que sejam estudados de forma independente.

Com isso, encerramos o trabalho com o objeto de detecção de tendências locais multiagente CNeuronPLRMultiAgentsOCL. O código completo de seus métodos pode ser consultado no anexo.

Módulo de atenção cruzada entre ativos (CSA)


Após obter a representação por partes lineares em múltiplas escalas da série temporal analisada, cada agente seleciona sua própria escala e realiza uma análise completa. O framework MASAAT prevê a análise da série temporal sob duas projeções: ativos e pontos no tempo.

A análise da série temporal no framework MASAAT é realizada pelo módulo de atenção cruzada entre ativos, que implementaremos como o objeto CNeuronCrossSectionalAnalysis. Mas antes de partir para a implementação, vamos discutir um pouco sobre o algoritmo de construção do módulo CSA.

Como foi explicado na descrição teórica do framework MASAAT, o módulo CSA utiliza um codificador com mecanismo de Self-Attention para analisar as dependências entre ativos. Nossa biblioteca já possui diversas implementações desses codificadores. Contudo, há um detalhe quando se trata da execução paralela de múltiplos agentes, em que cada agente analisa dependências apenas dentro de uma área específica dos dados brutos. Mas, com alguma reflexão, conseguimos encontrar uma solução adequada.

Por exemplo, o bloco de análise independente de canais individuais, CNeuronMVMHAttentionMLKV, que foi implementado no desenvolvimento do framework InjectTST. É uma boa solução. No entanto, esse bloco, em sua forma pura, trabalha sob uma lógica um pouco diferente: ele analisa dependências em diferentes escalas de um único ativo, enquanto o que precisamos é identificar dependências entre ativos dentro de uma mesma escala. Assim, antes de fornecer os dados brutos ao bloco de análise independente de canais, devemos transpor o tensor tridimensional analisado nos dois primeiros eixos. Vale mencionar que nossa biblioteca também possui uma camada de transposição para isso (CNeuronTransposeRCDOCL).

Parece que já definimos qual codificador será utilizado. No entanto, antes de enviar os dados para o codificador, precisamos criar os embeddings das trajetórias de cada ativo. Os autores do framework propõem usar uma MLP para isso, com parâmetros compartilhados entre todos os ativos. Como em casos semelhantes anteriores, optaremos por usar camadas convolucionais. Mais precisamente, adicionaremos apenas uma camada convolucional com GELU como função de ativação. E a função da segunda camada da MLP de geração de embeddings será assumida pela camada interna do codificador, responsável por formar as entidades Query, Key e Value.

Portanto, definimos a estrutura do nosso módulo CSA. Ele usará, em sequência, uma camada de transposição de dados, uma camada convolucional para os embeddings e o bloco de análise independente de canais. Mas vamos refletir um pouco: será que não seria melhor colocar primeiro a camada convolucional e depois transpor os dados? O resultado final das operações não mudaria. A questão está na eficiência da solução.

Na entrada do módulo CSA, fornecemos uma representação da série temporal do System: movimento de preços dos ativos analisados. Consequentemente, quanto maior a profundidade da história analisada, maior será o volume de dados brutos. E como estamos utilizando uma representação por partes lineares da série temporal, esperamos que grande parte dos dados brutos esteja preenchida com valores zero. Isso permite o uso de embeddings com tamanho muito menor. Sendo assim, ao posicionar a camada de transposição depois da camada convolucional de formação dos embeddings, conseguimos reduzir significativamente o tamanho do tensor transposto, o que diminui a quantidade de operações e melhora a eficiência da execução do modelo.

Com os principais aspectos da implementação definidos, podemos iniciar a construção do novo objeto CNeuronCrossSectionalAnalysis, cuja estrutura é apresentada a seguir.

class CNeuronCrossSectionalAnalysis :  public CNeuronMVMHAttentionMLKV
  {
protected:
   CNeuronConvOCL          cEmbeding;
   CNeuronTransposeRCDOCL  cTransposeRCD;
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      calcInputGradients(CNeuronBaseOCL *prevLayer) override;
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL) override;

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

Observe que escolhemos como classe base o bloco de análise independente de canais. Essa decisão nos permite não incluir esse bloco como um objeto interno, utilizando diretamente os recursos herdados para executar a funcionalidade necessária. Os demais objetos são declarados de forma estática, o que nos permite manter os métodos construtor e destrutor da classe vazios. A inicialização de todos os objetos ocorre no método Init, que herda integralmente a estrutura de parâmetros do método homônimo da classe base.

bool CNeuronCrossSectionalAnalysis::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                                         uint window, uint window_key, uint heads, uint heads_kv,
                                         uint units_count, uint layers, uint layers_to_one_kv, uint variables,
                                         ENUM_OPTIMIZATION optimization_type, uint batch)
  {
   if(!CNeuronMVMHAttentionMLKV::Init(numOutputs, myIndex, open_cl, window_key, window_key, heads, heads_kv,
                                      variables, layers, layers_to_one_kv, units_count, optimization_type, batch))
      return false;

No corpo do método, como de costume, chamamos primeiro o método com o mesmo nome da classe pai. Mas há um detalhe importante. Durante a implementação do módulo CSA, planejamos utilizar plenamente todos os métodos herdados. No processo de propagação para frente, pretendemos fornecer ao método da classe pai os embeddings transpostos dos dados brutos. Por isso, ao chamar o método de inicialização da classe base, ajustamos o tamanho da janela de dados brutos para a dimensão do embedding e trocamos os parâmetros de comprimento da sequência analisada com o número de variáveis independentes.

Após a execução bem-sucedida das operações de inicialização dos objetos da classe base, seguimos com a inicialização sequencial da camada convolucional de embedding e da transposição de dados.

   if(!cEmbeding.Init(0, 0, OpenCL, window, window, window_key, units_count, variables, optimization, iBatch))
      return false;
   cEmbeding.SetActivationFunction(GELU);
   if(!cTransposeRCD.Init(0,1,OpenCL,variables,units_count,window_key,optimization,iBatch))
      return false;

Depois disso, basta desativar manualmente a função de ativação e finalizar o método, retornando previamente o resultado lógico das operações ao programa chamador.

   SetActivationFunction(None);
//---
   return true;
  }

Em seguida, implementamos o algoritmo de propagação para frente do nosso módulo CSA no método feedForward. É importante mencionar que aqui não enfrentamos nenhuma complicação. Nos parâmetros do método, recebemos um ponteiro para o objeto dos dados brutos, que é imediatamente repassado ao método homônimo da camada convolucional de embeddings.

bool CNeuronCrossSectionalAnalysis::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   if(!cEmbeding.FeedForward(NeuronOCL))
      return false;
   if(!cTransposeRCD.FeedForward(cEmbeding.AsObject()))
     return false;
//---
   return CNeuronMVMHAttentionMLKV::feedForward(cTransposeRCD.AsObject());
  }

Os resultados do processamento dos dados brutos pela camada convolucional são transpostos e enviados ao método homônimo da classe base. Em seguida, finalizamos o método retornando o resultado lógico das operações para o programa chamador.

O algoritmo dos métodos de propagação reversa também é simples. Por isso, sugiro que sejam estudados de forma autônoma. Com isso, finalizamos o desenvolvimento do objeto CNeuronCrossSectionalAnalysis. O código completo de todos os seus métodos pode ser consultado no anexo.

Chegamos ao fim do artigo e encerramos nosso dia de trabalho. No entanto, o projeto ainda não está concluído. Faremos uma breve pausa e o levaremos a uma conclusão lógica no próximo artigo.



Considerações finais

Neste artigo, apresentamos a estrutura adaptativa multiagente para otimização de portfólio de investimentos com mecanismos de atenção e análise de séries temporais integrados — MASAAT, que utiliza um conjunto de agentes de trading para analisar os dados de preços sob diferentes perspectivas. Isso permite reduzir o viés nas ações de trading geradas. A análise cruzada baseada em mecanismos de atenção é empregada por cada agente para captar correlações entre ativos e pontos temporais durante o período de observação a partir de diferentes pontos de vista, seguida por um módulo de fusão espaço-temporal que tenta integrar as informações obtidas.

Na parte prática do artigo, iniciamos a implementação da nossa visão dos métodos propostos com o uso do MQL5. E no próximo artigo daremos continuidade a esse trabalho.  Além disso, verificaremos a eficácia da solução construída com dados históricos reais.


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 com o método Real-ORL
3 Study.mq5  Expert Advisor EA para treinamento de Modelos
4 Test.mq5 Expert Advisor EA para teste de modelo
5 Trajectory.mqh Biblioteca de classe Estrutura para descrever o estado do sistema
6 NeuroNet.mqh Biblioteca de classe Biblioteca de classes para criação de rede neural
7 NeuroNet.cl Biblioteca Biblioteca de código OpenCL

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

Arquivos anexados |
MQL5.zip (2222.61 KB)
ADAM Populacional (estimativa adaptativa de momentos) ADAM Populacional (estimativa adaptativa de momentos)
Este artigo apresenta a transformação do conhecido e popular método de otimização por gradiente ADAM em um algoritmo populacional e sua modificação com a introdução de indivíduos híbridos. A nova abordagem permite criar agentes que combinam elementos de soluções bem-sucedidas usando uma distribuição probabilística. A principal inovação é a formação de indivíduos híbridos populacionais, que acumulam de forma adaptativa informações das soluções mais promissoras, aumentando a eficácia da busca em espaços multidimensionais complexos.
Indicador de perfil de mercado — Market Profile (Parte 2): Otimização e desenho em canvas Indicador de perfil de mercado — Market Profile (Parte 2): Otimização e desenho em canvas
O artigo aborda uma versão otimizada do indicador de Perfil de Mercado Market Profile, onde, em vez de desenhar com diversos objetos gráficos, é utilizado o desenho em um canvas, ou seja, em um objeto da classe CCanvas.
Técnicas do MQL5 Wizard que você deve conhecer (Parte 37): Regressão por Processo Gaussiano com Núcleos Lineares e de Matérn Técnicas do MQL5 Wizard que você deve conhecer (Parte 37): Regressão por Processo Gaussiano com Núcleos Lineares e de Matérn
Os núcleos lineares são a matriz mais simples de seu tipo usada em aprendizado de máquina para regressão linear e máquinas de vetor de suporte. O núcleo de Matérn, por outro lado, é uma versão mais versátil da Função de Base Radial que analisamos em um artigo anterior, e é hábil em mapear funções que não são tão suaves quanto o RBF pressupõe. Construímos uma classe de sinal personalizada que utiliza ambos os núcleos para prever condições de compra e venda.
Redes neurais em trading: Modelo adaptativo multiagente (Conclusão) Redes neurais em trading: Modelo adaptativo multiagente (Conclusão)
No artigo anterior, conhecemos o framework adaptativo multiagente MASA, que combina abordagens de aprendizado por reforço com estratégias adaptativas, garantindo um equilíbrio harmônico entre lucratividade e riscos em condições turbulentas de mercado. Implementamos o funcional de agentes individuais deste framework, e neste artigo continuaremos o trabalho iniciado, levando-o à sua conclusão lógica.