Русский
preview
Trading algorítmico de arbitragem com teoria dos grafos

Trading algorítmico de arbitragem com teoria dos grafos

MetaTrader 5Integração |
19 0
Yevgeniy Koshtenko
Yevgeniy Koshtenko

Introdução

A negociação por arbitragem é uma das estratégias mais interessantes e tecnicamente complexas dos mercados financeiros. Essa estratégia busca aproveitar discrepâncias temporárias de preços entre diferentes instrumentos financeiros ou mercados para obter lucro com risco mínimo. No contexto do mercado Forex, a arbitragem busca e explora caminhos cíclicos de conversão de moedas, nos quais a moeda inicial e a moeda final coincidem, e o ciclo gera lucro depois que todos os custos transacionais são considerados.

Na arbitragem, o trader compra e vende simultaneamente instrumentos financeiros relacionados a preços diferentes e captura a diferença como lucro. Por exemplo, a arbitragem triangular clássica pode envolver o ciclo USD → EUR → GBP → USD. Se o produto das taxas de câmbio nessa cadeia for maior que 1 mesmo após a dedução dos spreads e das comissões, esse ciclo se torna lucrativo.

É importante entender que oportunidades de arbitragem nos mercados financeiros modernos são extremamente raras e de curta duração. Isso se deve à alta eficiência dos mercados, à presença do trading algorítmico e à alta velocidade de circulação das informações. Ainda assim, tecnologias modernas e algoritmos corretamente configurados podem ajudar a identificar e aproveitar essas oportunidades.

Neste artigo, vamos analisar como criar um EA totalmente funcional em MQL5, capaz de detectar automaticamente oportunidades de arbitragem, calcular os tamanhos ótimos das posições e gerenciar riscos por meio de um sistema de preço médio das posições.


Fundamentos teóricos da arbitragem

Representação do mercado de moedas como grafo

Um grafo orientado e ponderado representa a arbitragem cambial como um problema de busca de ciclos lucrativos de maneira elegante. Nesse modelo, cada vértice representa uma moeda (como USD, EUR, GBP e JPY), e as arestas correspondem aos pares de moedas (como EUR/USD, GBP/USD e USD/JPY), cada um com seu respectivo peso.

Os pesos das arestas são definidos da seguinte forma: para uma operação de compra (transição da moeda-base para a moeda cotada), o peso é igual a 1/ask_price; para uma operação de venda (transição da moeda cotada para a moeda-base), o peso é igual a bid_price. Um ciclo de arbitragem lucrativo é um caminho fechado no grafo em que o produto dos pesos de todas as arestas supera 1 após a dedução dos custos transacionais.

Fórmula matemática do lucro

A seguinte fórmula calcula o lucro de um ciclo de arbitragem:

Прибыль = w₁ × w₂ × ... × wₙ - 1 - Спред

Onde wᵢ é o peso da i-ésima aresta no ciclo, e Spread é o custo relativo total dos spreads de todas as operações. O sistema calcula o spread de cada par de moedas como valor relativo: (ask - bid) / bid.

Condição de exposição zero

Um requisito fundamental para a arbitragem propriamente dita é manter exposição cambial zero. Isso significa que o volume total de cada moeda no portfólio, após a execução de todas as operações do ciclo, deve ser igual a zero. Matematicamente, isso é expresso por meio de um sistema de equações lineares, no qual as compras de uma moeda são compensadas por suas vendas.

Para alcançar exposição zero, é necessário calcular corretamente os tamanhos dos lotes para cada operação do ciclo, levando em conta as especificações de cada par de moedas na corretora.


Arquitetura do EA de arbitragem

Abordagem de projeto do sistema

O desenvolvimento de um EA de arbitragem exige uma abordagem sistêmica e um planejamento arquitetural cuidadoso. O sistema é construído com base nos princípios de modularidade, escalabilidade e tolerância a falhas. A ideia central é criar componentes independentes, cada um responsável por uma funcionalidade específica e que pode ser modificado sem afetar as demais partes do sistema.

O módulo que constrói o grafo cria o modelo matemático do mercado de moedas com base nas cotações atuais. Esse componente deve processar os dados da corretora, filtrar os instrumentos de acordo com critérios definidos e criar a estrutura do grafo na memória. O desempenho exige atenção especial, pois o sistema deve reconstruir o grafo em tempo real quando há alteração das cotações.

O módulo algorítmico inclui a implementação de duas abordagens complementares: o algoritmo Floyd-Warshall para busca de caminhos ideais e o método de busca em profundidade (DFS) para a análise exaustiva de todos os ciclos possíveis. Essa abordagem dual combina eficiência na busca com uma cobertura ampla das possíveis oportunidades de arbitragem.

O módulo de balanceamento de posições resolve o problema matemático de manter exposição cambial zero. Nesse módulo, são implementados algoritmos de cálculo dos tamanhos dos lotes que levam em conta as especificações da corretora, incluindo tamanhos mínimos de lote, passos de volume e requisitos de margem.

O módulo de negociação gerencia a execução das operações, incluindo o envio de ordens, o monitoramento de seu status e o tratamento de erros de execução. Uma característica importante é a implementação de um sistema de estratégias de fallback para os casos em que nem todas as ordens do ciclo podem ser executadas simultaneamente.

O módulo que gerencia riscos implementa estratégias avançadas: calcula o preço médio das posições, ajusta dinamicamente os tamanhos das posições e aciona sistemas de fechamento de emergência quando os limites de perda são ultrapassados.

Projeto das estruturas básicas

A implementação eficiente de um EA de arbitragem começa pela modelagem adequada das estruturas de dados. A estrutura Edge representa uma aresta do grafo e contém todas as informações necessárias para executar uma operação de negociação. Os campos from e to definem a direção da conversão de moedas, weight contém a taxa de câmbio, e spread representa o custo relativo da transação.

#property strict
#include <Trade/Trade.mqh>
CTrade trade;

#define MAX_VERTICES 25
#define MAX_EDGES    500
#define MAX_CYCLE_LENGTH 15
#define INF 999999.0

struct Edge {
   int from;           // Исходная вершина
   int to;             // Целевая вершина
   double weight;      // Вес ребра (обменный курс)
   double spread;      // Относительный спред
   string symbol;      // Название валютной пары
   bool is_buy;        // Тип операции (покупка/продажа)
};

struct Vertex {
   string name;        // Название валюты (USD, EUR и т.д.)
};

struct ArbitragePath {
   Edge path_edges[MAX_CYCLE_LENGTH];      // Рёбра пути
   int path_vertices[MAX_CYCLE_LENGTH + 1]; // Вершины пути
   int length;                              // Длина пути
   double total_rate;                       // Общий курс
   double total_spread;                     // Общий спред
   double net_profit;                       // Чистая прибыль
   string description;                      // Описание пути
};

Os arrays globais para armazenamento do grafo são alocados estaticamente para priorizar o máximo desempenho. As constantes MAX_VERTICES e MAX_EDGES definem os tamanhos máximos das estruturas e devem ser equilibradas entre as necessidades de memória e o desempenho.

As matrizes dist e spread_matrix são usadas pelo algoritmo Floyd-Warshall e têm dimensão MAX_VERTICES × MAX_VERTICES. A matriz next_vertex é necessária para reconstruir os caminhos encontrados. Essas estruturas exigem um volume significativo de memória, mas oferecem acesso O(1) aos dados.

O array all_arbitrage_paths armazena todas as oportunidades de arbitragem encontradas e permite ordená-las por lucratividade. O tamanho do array (1000 elementos) foi escolhido com base em testes práticos e pode ser ajustado conforme os requisitos do sistema.

O sistema dá atenção especial à otimização das operações com arrays. Todos os laços críticos usam endereçamento direto em vez de funções de acesso, o que minimiza a sobrecarga e aumenta o desempenho dos algoritmos de busca de arbitragem.

// Параметры советника
extern double LotSize = 0.1;           // Максимальный размер лота
extern double MinProfit = 0.0005;      // Минимальная прибыль (0.05%)
extern int MaxSpreadPoints = 25;       // Максимальный спред в пунктах
extern int MaxPathLength = 4;          // Максимальная длина цикла
extern int MaxAverages = 2;            // Максимальное количество усреднений
extern double PriceImprovementPoints = 1.0; // Улучшение цены в пунктах
extern bool ShowDetailedPaths = true;  // Показать детальные пути

// Массивы для графа
Edge edges[MAX_EDGES];
Vertex vertices[MAX_VERTICES];
double dist[MAX_VERTICES][MAX_VERTICES];
double spread_matrix[MAX_VERTICES][MAX_VERTICES];
int next_vertex[MAX_VERTICES][MAX_VERTICES];


Algoritmos de busca de arbitragem

Algoritmo para construir o grafo

A função BuildGraph() é um componente essencial do sistema, responsável por criar o modelo matemático do mercado de moedas. A execução começa inicializando todas as estruturas de dados e definindo os valores iniciais das matrizes de distâncias e spreads.

void BuildGraph() {
   vertex_count = 0;
   edge_count = 0;
   
   // Инициализация матриц расстояний и спредов
   for(int i=0; i<MAX_VERTICES; i++) {
      for(int j=0; j<MAX_VERTICES; j++) {
         dist[i][j] = (i == j) ? 1.0 : INF;
         spread_matrix[i][j] = (i == j) ? 0.0 : INF;
         next_vertex[i][j] = -1;
      }
   }
   
   MqlTick tick;
   for(int i=0; i<ArraySize(symbols); i++) {
      if(SymbolInfoTick(symbols[i], tick)) {
         // Расчёт спреда в пунктах и относительного спреда
         double point = GetPoint(symbols[i]);
         double spread_points = (tick.ask - tick.bid) / point;
         double spread_cost = (tick.ask - tick.bid) / tick.bid;
         
         // Фильтрация по максимальному спреду
         if(spread_points <= MaxSpreadPoints) {
            string base = StringSubstr(symbols[i], 0, 3);
            string quote = StringSubstr(symbols[i], 3, 3);
            
            // Добавление рёбер для покупки и продажи
            AddEdge(base, quote, 1.0/tick.ask, spread_cost, symbols[i], true);
            AddEdge(quote, base, tick.bid, spread_cost, symbols[i], false);
         } else {
            PrintFormat("[WARNING] High spread for %s: %.1f points", symbols[i], spread_points);         }
      }
   }
   
   PrintFormat("Graph built: %d vertices, %d edges", vertex_count, edge_count);
}

Os elementos diagonais da matriz de distâncias são definidos como 1.0, o que corresponde à operação identidade de conversão da moeda para ela mesma com custo zero. Os demais elementos são inicializados com o valor INF (infinito), indicando a ausência de conexão direta entre as moedas.

O laço principal processa o array de símbolos dos pares de moedas, obtendo as cotações atuais por meio de SymbolInfoTick(). Para cada par, o algoritmo calcula o spread em pontos e o spread relativo. Pares com spread excessivamente alto são excluídos da análise para aumentar a qualidade das oportunidades encontradas.

for(int i=0; i<ArraySize(symbols); i++) {
   if(SymbolInfoTick(symbols[i], tick)) {
      double point = GetPoint(symbols[i]);
      double spread_points = (tick.ask - tick.bid) / point;
      double spread_cost = (tick.ask - tick.bid) / tick.bid;
      
      if(spread_points <= MaxSpreadPoints) {
         string base = StringSubstr(symbols[i], 0, 3);
         string quote = StringSubstr(symbols[i], 3, 3);
         
         AddEdge(base, quote, 1.0/tick.ask, spread_cost, symbols[i], true);
         AddEdge(quote, base, tick.bid, spread_cost, symbols[i], false);
      }
   }
}

A função AddEdge() adiciona arestas ao grafo e cria automaticamente novos vértices quando necessário. A função AddVertex() usa busca linear para verificar se a moeda já existe no grafo e preserva a unicidade dos vértices.

Cada par de moedas cria duas arestas: uma para compra (transição da moeda-base para a moeda cotada) e outra para venda (transição inversa). O peso da aresta de compra é igual a 1/ask, representando a quantidade de moeda cotada recebida por uma unidade da moeda-base. O peso da aresta de venda é igual a bid, mostrando a quantidade de moeda-base recebida por uma unidade da moeda cotada.

Além disso, as arestas são armazenadas na matriz edge_matrix para acesso rápido ao reconstruir os caminhos. Essa otimização é crítica para o desempenho do algoritmo Floyd-Warshall.

void AddEdge(string from_currency, string to_currency, double weight, 
             double spread, string symbol, bool is_buy) {
   if(edge_count >= MAX_EDGES) return;
   
   int from_idx = GetOrAddVertexIndex(from_currency);
   int to_idx = GetOrAddVertexIndex(to_currency);
   
   edges[edge_count].from = from_idx;
   edges[edge_count].to = to_idx;
   edges[edge_count].weight = weight;
   edges[edge_count].spread = spread;
   edges[edge_count].symbol = symbol;
   edges[edge_count].is_buy = is_buy;
   
   edge_count++;
}

int GetOrAddVertexIndex(string currency) {
   // Поиск существующей вершины
   for(int i=0; i<vertex_count; i++) {
      if(vertices[i].name == currency) return i;
   }
   
   // Добавление новой вершины
   if(vertex_count < MAX_VERTICES) {
      vertices[vertex_count].name = currency;
      return vertex_count++;
   }
   return -1;
}

O sistema trata diferentes tipos de pares de moedas, incluindo pares exóticos e instrumentos com número não padrão de casas decimais. A função GetPoint() processa corretamente cotações de 4 e 5 dígitos e mantém compatibilidade com diferentes corretoras.

Para pares com JPY, aplica-se uma lógica específica de cálculo de spreads, levando em conta o número tradicionalmente menor de casas decimais. Isso evita a exclusão indevida desses pares da análise e melhora a qualidade das oportunidades de arbitragem encontradas.

Fundamentos matemáticos do algoritmo Floyd-Warshall

O algoritmo Floyd-Warshall, no contexto da arbitragem cambial, exige uma modificação significativa da versão clássica. O algoritmo padrão busca caminhos mais curtos minimizando a soma dos pesos das arestas, mas, para arbitragem, precisamos maximizar o produto das taxas de câmbio.

A principal modificação substitui a operação de soma pela multiplicação e a busca pelo mínimo pela busca pelo máximo. No entanto, a simples substituição das operações não é suficiente; também é necessário considerar o acúmulo de spreads e evitar instabilidade numérica ao trabalhar com produtos.

void FloydWarshall() {
   // Инициализация матриц
   for(int i=0; i<vertex_count; i++) {
      for(int j=0; j<vertex_count; j++) {
         if(i == j) {
            dist[i][j] = 1.0;           // Диагональные элементы = 1
            spread_matrix[i][j] = 0.0;  // Нулевой спред для одной валюты
         } else {
            dist[i][j] = INF;           // Бесконечность для недостижимых пар
            spread_matrix[i][j] = INF;
         }
         next_vertex[i][j] = -1;        // Матрица для восстановления путей
      }
   }
   
   // Заполнение прямых рёбер
   for(int e=0; e<edge_count; e++) {
      int from = edges[e].from;
      int to = edges[e].to;
      
      if(from >= 0 && to >= 0 && from < vertex_count && to < vertex_count) {
         // Выбор лучшего курса для данной пары валют
         if(dist[from][to] == INF || edges[e].weight > dist[from][to]) {
            dist[from][to] = edges[e].weight;
            spread_matrix[from][to] = edges[e].spread;
            next_vertex[from][to] = to;
         }
      }
   }
   
   // Основной цикл Floyd-Warshall
   for(int k=0; k<vertex_count; k++) {
      for(int i=0; i<vertex_count; i++) {
         for(int j=0; j<vertex_count; j++) {
            if(dist[i][k] != INF && dist[k][j] != INF) {
               double new_rate = dist[i][k] * dist[k][j];
               double new_spread = spread_matrix[i][k] + spread_matrix[k][j];
               
               // Обновление пути если он лучше по курсу и приемлем по спреду
               if(new_rate > dist[i][j] && new_spread < spread_matrix[i][j] * 2) {
                  dist[i][j] = new_rate;
                  spread_matrix[i][j] = new_spread;
                  next_vertex[i][j] = next_vertex[i][k];
               }
            }
         }
      }
   }
   
   Print("[SUCCESS] Floyd-Warshall algorithm completed");
}

Ao lidar com produtos de taxas de câmbio, surgem problemas de estabilidade numérica. Os produtos podem se tornar muito grandes ou muito pequenos, levando à perda de precisão. Para resolver esse problema, foi introduzida uma condição adicional de verificação dos spreads.

A condição new_spread < spread_matrix[i][j] * 2 evita o acúmulo de spreads excessivamente altos, que podem anular o lucro potencial. O coeficiente 2 foi escolhido empiricamente e pode ser ajustado conforme as condições de mercado.

A matriz next_vertex permite reconstruir os caminhos encontrados. Ao atualizar a distância entre os vértices i e j por meio de um vértice intermediário k, o algoritmo armazena a informação de que o próximo vértice no caminho de i até j é o mesmo que o próximo vértice no caminho de i até k.

Como reconstruir os caminhos de arbitragem

A função ReconstructPath() usa a matriz next_vertex para reconstruir a sequência de arestas que compõem o ciclo de arbitragem encontrado. A execução começa no vértice inicial e avança sequencialmente pelos vértices seguintes até retornar ao ponto de origem.

void FindArbitrageFromFloydWarshall() {
   for(int i=0; i<vertex_count; i++) {
      if(dist[i][i] > 1.0) {  // Прибыльный цикл найден
         double net_profit = dist[i][i] - 1.0 - spread_matrix[i][i];
         if(net_profit > MinProfit) {
            PrintFormat("Floyd-Warshall arbitrage: %s -> %s, profit: %.4f%%", 
                       vertices[i].name, vertices[i].name, net_profit*100);
         }
      }
   }
}

Um ponto crítico é a correspondência correta entre os caminhos encontrados e as arestas originais do grafo. Como podem existir várias arestas entre duas moedas (compra e venda), é necessário escolher a aresta correta, correspondente ao caminho ideal encontrado.

O algoritmo Floyd-Warshall tem complexidade temporal O(n³), o que pode se tornar um gargalo quando o número de moedas é grande. Para otimizar o algoritmo, são aplicadas várias técnicas: filtragem prévia das moedas por liquidez, uso de estruturas de dados mais eficientes e paralelização dos cálculos, sempre que possível.

Algoritmo de busca em profundidade

O método de busca em profundidade (DFS) complementa o Floyd-Warshall com uma análise exaustiva de todos os ciclos de arbitragem possíveis. Diferentemente do Floyd-Warshall, que encontra caminhos ideais entre pares de vértices, o DFS pode detectar ciclos alternativos que podem se revelar lucrativos em determinadas condições de mercado.

A função AdvancedDFS() implementa uma busca recursiva com várias otimizações. Ela percorre sistematicamente todos os caminhos possíveis a partir de cada moeda, verifica se o ciclo pode ser fechado e calcula a lucratividade potencial.

void AdvancedDFS(int start, int current, double product, double spread_cost, 
                 Edge &path_edges[], int &path_vertices[], int depth) {
   // Ограничение глубины поиска для предотвращения переполнения стека
   if(depth >= MaxPathLength) return;
   
   // Перебор всех исходящих рёбер из текущей вершины
   for(int i=0; i<edge_count; i++) {
      if(edges[i].from == current) {
         // Проверка на повторное использование валютной пары
         bool symbol_used = false;
         for(int j=0; j<depth; j++) {
            if(path_edges[j].symbol == edges[i].symbol) {
               symbol_used = true;
               break;
            }
         }
         if(symbol_used) continue;  // Пропуск уже использованной пары
         
         // Добавление ребра к текущему пути
         path_edges[depth] = edges[i];
         path_vertices[depth + 1] = edges[i].to;
         double new_product = product * edges[i].weight;
         double new_spread = spread_cost + edges[i].spread;
         
         // Проверка замыкания цикла
         if(edges[i].to == start && depth >= 2) {
            double net_profit = new_product - 1.0 - new_spread;
            
            // Сохранение прибыльного цикла
            if(net_profit > MinProfit && arbitrage_path_count < ArraySize(all_arbitrage_paths)) {
               ArbitragePath arb_path;
               arb_path.length = depth + 1;
               arb_path.total_rate = new_product;
               arb_path.total_spread = new_spread;
               arb_path.net_profit = net_profit;
               
               // Копирование пути
               for(int k=0; k<=depth; k++) {
                  arb_path.path_edges[k] = path_edges[k];
                  arb_path.path_vertices[k] = path_vertices[k];
               }
               arb_path.path_vertices[depth + 1] = start;
               
               // Формирование описания пути
               arb_path.description = "";
               for(int k=0; k<=depth; k++) {
                  if(k > 0) arb_path.description += " -> ";
                  arb_path.description += vertices[path_vertices[k]].name;
               }
               arb_path.description += " -> " + vertices[start].name;
               
               all_arbitrage_paths[arbitrage_path_count++] = arb_path;
               
               if(ShowDetailedPaths) {
                  
PrintFormat("[FOUND] Arbitrage: %s, profit: %.4f%%, spread: %.4f%%",
                                arb_path.description, net_profit*100, new_spread*100); 
               }
            }
         } else {
            // Продолжение поиска в глубину
            AdvancedDFS(start, edges[i].to, new_product, new_spread, 
                       path_edges, path_vertices, depth + 1);
         }
      }
   }
}

Um ponto crítico da implementação é impedir o uso repetido do mesmo instrumento cambial dentro de um mesmo ciclo. Essa restrição decorre de considerações práticas: a abertura simultânea de posições opostas no mesmo instrumento pode criar problemas de execução e aumentar os custos transacionais.

O código verifica symbol_used comparando o nome do instrumento atual com todos os instrumentos já incluídos no caminho atual. Com isso, o ciclo usa cada instrumento no máximo uma vez.

Otimização da profundidade da busca

O parâmetro MaxPathLength limita o comprimento máximo dos ciclos analisados. Essa restrição atende a vários objetivos: evitar estouro de pilha em chamadas recursivas, limitar o tempo de execução do algoritmo e manter o foco em oportunidades de arbitragem realizáveis na prática.

Estudos empíricos mostram que a maioria dos ciclos de arbitragem úteis na prática tem comprimento de 3 a 5 operações. Ciclos mais longos geralmente apresentam spreads totais elevados, o que reduz sua lucratividade a um nível inaceitável.

Cálculo e armazenamento dos ciclos encontrados

Ao detectar uma aresta de fechamento (quando o vértice de destino é o mesmo que o vértice inicial), o algoritmo calcula as métricas de lucratividade do ciclo. Ele calcula o lucro líquido como a diferença entre o produto das taxas de câmbio, uma unidade e o spread total.

if(edges[i].to == start && depth >= 2) {
   double net_profit = new_product - 1.0 - new_spread;
   
   if(net_profit > MinProfit && arbitrage_path_count < ArraySize(all_arbitrage_paths)) {
      ArbitragePath arb_path;
      arb_path.length = depth + 1;
      arb_path.total_rate = new_product;
      arb_path.total_spread = new_spread;
      arb_path.net_profit = net_profit;

Para cada ciclo encontrado, o algoritmo monta uma descrição textual que inclui a sequência de moedas e as direções das operações. Essa informação é essencial para a análise e a depuração das oportunidades de arbitragem encontradas.

O algoritmo cria a descrição concatenando os nomes das moedas com o separador "→", o que fornece uma representação visual clara do sentido da conversão. Além disso, armazena informações sobre os tipos de operação (compra/venda) para cada aresta do ciclo.


Balancear posições e gerenciar riscos

Fundamentos matemáticos do balanceamento

Manter exposição cambial zero é um requisito fundamental para a arbitragem propriamente dita. A função CalculateBalancedLots() resolve um problema matemático complexo: determinar os tamanhos das posições que deixam o volume líquido igual a zero em cada moeda após a execução de todas as operações do ciclo.

O algoritmo começa com um valor-base (10000 unidades), que representa o capital inicial na moeda a partir da qual o ciclo de arbitragem começa. Cada aresta do ciclo transforma esse valor sequencialmente, levando em conta as respectivas taxas de câmbio.

Manter exposição cambial zero é um aspecto crítico da negociação por arbitragem. Exposição zero significa que, após a execução de todas as operações do ciclo de arbitragem, a posição líquida em cada moeda deve ser igual a zero. Com isso, o lucro deixa de depender dos movimentos posteriores das taxas de câmbio.

Para alcançar exposição zero, é necessário calcular cuidadosamente o tamanho de cada posição no ciclo. O cálculo começa com um valor fixo na moeda inicial e o cálculo aplica sequencialmente as taxas de câmbio para determinar os valores equivalentes nas demais moedas.

A exposição total em cada moeda deve ficar próxima de zero, com uma tolerância admissível de 0.01 lote.

Algoritmo de cálculo dos lotes balanceados
bool CalculateBalancedLots(ArbitragePath &path, double &lots[]) {
   if(path.length == 0) return false;
   
   double base_amount = 1000.0;  // Начальная сумма в базовой валюте
   double current_amount = base_amount;
   
   // Получение спецификаций первого инструмента
   MqlTick tick;
   if(!SymbolInfoTick(path.path_edges[0].symbol, tick)) return false;
   
   double contract_size = SymbolInfoDouble(path.path_edges[0].symbol, SYMBOL_TRADE_CONTRACT_SIZE);
   double min_lot = SymbolInfoDouble(path.path_edges[0].symbol, SYMBOL_VOLUME_MIN);
   double step_lot = SymbolInfoDouble(path.path_edges[0].symbol, SYMBOL_VOLUME_STEP);
   
   // Расчёт размера первого лота
   lots[0] = NormalizeDouble(base_amount / contract_size, 2);
   lots[0] = MathMax(min_lot, MathRound(lots[0] / step_lot) * step_lot);
   
   // Последовательный расчёт размеров лотов для остальных сделок
   for(int i = 1; i < path.length; i++) {
      // Применение обменного курса предыдущего ребра
      current_amount *= path.path_edges[i-1].weight;
      
      // Получение спецификаций текущего инструмента
      string sym = path.path_edges[i].symbol;
      if(!SymbolInfoTick(sym, tick)) return false;
      
      contract_size = SymbolInfoDouble(sym, SYMBOL_TRADE_CONTRACT_SIZE);
      min_lot = SymbolInfoDouble(sym, SYMBOL_VOLUME_MIN);
      step_lot = SymbolInfoDouble(sym, SYMBOL_VOLUME_STEP);
      
      // Расчёт и нормализация размера лота
      lots[i] = NormalizeDouble(current_amount / contract_size, 2);
      lots[i] = MathMax(min_lot, MathRound(lots[i] / step_lot) * step_lot);
   }
   
   // Масштабирование всех лотов под максимальный допустимый размер
   double max_lot = 0;
   for(int i = 0; i < path.length; i++) {
      if(lots[i] > max_lot) max_lot = lots[i];
   }
   
   if(max_lot > LotSize) {
      double scale = LotSize / max_lot;
      for(int i = 0; i < path.length; i++) {
         lots[i] *= scale;
         lots[i] = MathMax(SymbolInfoDouble(path.path_edges[i].symbol, SYMBOL_VOLUME_MIN), lots[i]);
      }
   }
   
   return true;
}

Um aspecto crítico é considerar as especificações dos instrumentos de negociação em uma corretora específica. Cada instrumento tem seu próprio tamanho de contrato (contract_size), tamanho mínimo de lote (min_lot) e passo de lote (step_lot). Esses parâmetros devem ser rigorosamente respeitados para que as ordens sejam enviadas com sucesso.

O sistema calcula o tamanho do lote para cada operação dividindo o valor atual na moeda correspondente pelo tamanho do contrato. Em seguida, normaliza o valor obtido para o tamanho de lote permitido mais próximo, levando em conta o tamanho mínimo e o passo de variação.

Após calcular todos os lotes, o sistema verifica se o maior lote excede o limite definido por LotSize. Se o limite for excedido, todos os lotes são redimensionados proporcionalmente para baixo, preservando suas proporções relativas.

Esse escalonamento é crítico para gerenciar riscos, pois permite limitar a exposição máxima em qualquer instrumento individual. No entanto, é necessário verificar se, após o escalonamento, todos os lotes continuam acima dos requisitos mínimos da corretora.

Uma função adicional (não mostrada no código básico, mas crítica) deve verificar se os lotes calculados realmente mantêm exposição zero. Para isso, ela cria um mapa das posições por moeda e soma os volumes de compras e vendas para cada moeda.

Verificação da exposição cambial:

bool VerifyZeroExposure(ArbitragePath &path, double lots[]) {
   // Создание карты валютных позиций
   string currencies[MAX_VERTICES];
   double exposures[MAX_VERTICES];
   int currency_count = 0;
   
   for(int i = 0; i < path.length; i++) {
      string base = StringSubstr(path.path_edges[i].symbol, 0, 3);
      string quote = StringSubstr(path.path_edges[i].symbol, 3, 3);
      
      // Обновление экспозиции по базовой валюте
      AddExposure(currencies, exposures, currency_count, base, 
                 path.path_edges[i].is_buy ? lots[i] : -lots[i]);
      
      // Обновление экспозиции по котируемой валюте
      AddExposure(currencies, exposures, currency_count, quote, 
                 path.path_edges[i].is_buy ? -lots[i] : lots[i]);
   }
   
   // Проверка близости к нулю для всех валют
   for(int i = 0; i < currency_count; i++) {
      if(MathAbs(exposures[i]) > 0.01) {
         PrintFormat("Non-zero exposure for %s: %.4f", currencies[i], exposures[i]);
         return false;
      }
   }
   
   return true;
}
Estratégia de preço médio das posições

O mecanismo de preço médio das posições é um componente importante para gerenciar riscos no EA de arbitragem. Quando o mercado se move contra uma posição aberta, o EA pode abrir uma posição adicional na mesma direção, reduzindo o preço médio de entrada e potencialmente melhorando o resultado geral.

No entanto, o preço médio deve ser aplicado com cautela, pois aumenta o tamanho total da posição e, consequentemente, o risco potencial. Por isso, o sistema limita o número de entradas de preço médio com o parâmetro MaxAverages.

Implementação do sistema de preço médio
int CountAverages(string symbol, bool is_buy) {
   int count = 0;
   ENUM_POSITION_TYPE pos_type = is_buy ? POSITION_TYPE_BUY : POSITION_TYPE_SELL;
   
   for(int i = 0; i < PositionsTotal(); i++) {
      if(PositionGetSymbol(i) == symbol && 
         (ENUM_POSITION_TYPE)PositionGetInteger(POSITION_TYPE) == pos_type) {
         count++;
      }
   }
   return count;
}

bool HasOpenPosition(string symbol) {
   for(int i = 0; i < PositionsTotal(); i++) {
      if(PositionGetSymbol(i) == symbol) return true;
   }
   return false;
}

bool HasPendingOrder(string symbol) {
   for(int i = 0; i < OrdersTotal(); i++) {
      if(OrderGetString(ORDER_SYMBOL) == symbol) return true;
   }
   return false;
}
Função de abertura de posições com preço médio:
bool OpenTradeFromEdge(Edge &edge, double balanced_lot) {
   // Проверка существующих pending ордеров
   if(HasPendingOrder(edge.symbol)) return true;
   
   MqlTick tick;
   if(!SymbolInfoTick(edge.symbol, tick)) {
      PrintFormat("Failed to get tick for %s", edge.symbol);
      return false;
   }
   
   int digits = (int)SymbolInfoInteger(edge.symbol, SYMBOL_DIGITS);
   ENUM_ORDER_TYPE order_type;
   double price;
   
   bool is_averaging = false;
   int avg_count = CountAverages(edge.symbol, edge.is_buy);
   
   if(HasOpenPosition(edge.symbol) && avg_count < MaxAverages) {
      // Режим усреднения - размещение рыночного ордера
      is_averaging = true;
      order_type = edge.is_buy ? ORDER_TYPE_BUY : ORDER_TYPE_SELL;
      price = 0;  // Рыночная цена
      
      PrintFormat("Averaging %d time for %s %s (lot: %.2f)", 
                 avg_count + 1, edge.is_buy ? "BUY" : "SELL", edge.symbol, balanced_lot);
                 
   } else if(!HasOpenPosition(edge.symbol)) {
      // Первоначальный вход - размещение лимитного ордера с улучшением цены
      order_type = edge.is_buy ? ORDER_TYPE_BUY_LIMIT : ORDER_TYPE_SELL_LIMIT;
      price = NormalizeDouble(
         edge.is_buy ? tick.bid - PriceImprovementPoints * GetPoint(edge.symbol) 
                     : tick.ask + PriceImprovementPoints * GetPoint(edge.symbol), 
         digits);
   } else {
      PrintFormat("Max averages reached for %s, skipping", edge.symbol);
      return true;
   }
   
   // Размещение ордера
   trade.SetExpertMagicNumber(999);
   if(trade.OrderOpen(edge.symbol, order_type, balanced_lot, 0, price, 0, 0)) {
      string type_str = is_averaging ? "market (average)" : "limit";
      PrintFormat("Placed %s %s order: %s at %.5f (lot: %.2f)", 
                 edge.is_buy ? "BUY" : "SELL", type_str, edge.symbol, price, balanced_lot);
      return true;
   } else {
      PrintFormat("Failed to place order: %s %s at %.5f (lot: %.2f): %s", 
                 edge.is_buy ? "BUY" : "SELL", edge.symbol, price, balanced_lot,
                 trade.ResultRetcodeDescription());
      return false;
   }
}

Monitorar e gerenciar posições:

void MonitorPositions() {
   double total_profit = 0;
   int total_positions = 0;
   
   for(int i = 0; i < PositionsTotal(); i++) {
      if(PositionGetInteger(POSITION_MAGIC) == 999) {
         total_profit += PositionGetDouble(POSITION_PROFIT);
         total_positions++;
      }
   }
   
   if(total_positions > 0) {
      PrintFormat("📊 Active positions: %d, Total P&L: %.2f", total_positions, total_profit);
      
      // Закрытие прибыльных арбитражных циклов
      if(total_profit > 10.0) {  // Минимальная прибыль для закрытия
         CloseAllArbitragePositions();
      }
   }
}

void CloseAllArbitragePositions() {
   for(int i = PositionsTotal() - 1; i >= 0; i--) {
      if(PositionGetInteger(POSITION_MAGIC) == 999) {
         trade.PositionClose(PositionGetSymbol(i));
      }
   }
   PrintFormat("Closed all arbitrage positions");
}
Estratégia de execução

A execução da estratégia de arbitragem exige a coordenação de múltiplas ordens em diferentes pares de moedas. O EA usa uma abordagem combinada: ordens limitadas para a entrada inicial com melhoria de preço e ordens a mercado para o preço médio das posições.

Os princípios centrais de execução incluem enviar simultaneamente todas as ordens do ciclo, controlar a execução com cancelamento de todo o grupo em caso de falha de qualquer ordem e gerenciar riscos monitorando a lucratividade total do portfólio.

Função principal de execução
void ExecuteBestArbitrage() {
   if(arbitrage_path_count == 0) return;
   
   // Сортировка путей по прибыльности
   SortArbitragePaths();
   
   ArbitragePath best = all_arbitrage_paths[0];
   PrintFormat("Executing best arbitrage: %s, profit: %.4f%%", 
              best.description, best.net_profit*100);
   
   // Расчёт балансированных лотов
   double balanced_lots[MAX_CYCLE_LENGTH];
   if(!CalculateBalancedLots(best, balanced_lots)) {
      Print("Failed to calculate balanced lots");
      return;
   }
   
   // Проверка валютной экспозиции
   if(!VerifyZeroExposure(best, balanced_lots)) {
      Print("Non-zero exposure detected, skipping execution");
      return;
   }
   
   // Размещение всех ордеров цикла
   bool all_orders_placed = true;
   for(int i=0; i<best.length; i++) {
      if(!OpenTradeFromEdge(best.path_edges[i], balanced_lots[i])) {
         all_orders_placed = false;
         break;
      }
   }
   
   // Контроль успешности размещения
   if(!all_orders_placed) {
      PrintFormat("Failed to place all orders, cancelling pending orders");
      CancelPendingOrders();
   } else {
      last_arbitrage_time = TimeCurrent();
      PrintFormat("Successfully placed %d orders for arbitrage cycle", best.length);
   }
}

void SortArbitragePaths() {
   // Простая сортировка пузырьком по убыванию прибыльности
   for(int i = 0; i < arbitrage_path_count - 1; i++) {
      for(int j = 0; j < arbitrage_path_count - i - 1; j++) {
         if(all_arbitrage_paths[j].net_profit < all_arbitrage_paths[j + 1].net_profit) {
            ArbitragePath temp = all_arbitrage_paths[j];
            all_arbitrage_paths[j] = all_arbitrage_paths[j + 1];
            all_arbitrage_paths[j + 1] = temp;
         }
      }
   }
}

Para o preço médio, são usadas ordens a mercado, o que prioriza a execução, mas pode levar a slippage. Para a entrada inicial, são usadas ordens limitadas com melhoria de preço, o que aumenta a probabilidade de obter um preço melhor na execução.

O sistema inclui mecanismos de controle e cancelamento de ordens limitadas não executadas. A função HasPendingOrder() verifica a existência de ordens ativas para o instrumento, evitando a duplicação de ordens.

bool HasPendingOrder(string symb) {
   for(int i=OrdersTotal()-1; i>=0; i--) {
      if(OrderSelect(OrderGetTicket(i))) {
         if(OrderGetString(ORDER_SYMBOL) == symb)
            return true;
      }
   }
   return false;
}

A função CancelPendingOrders() centraliza o cancelamento de todas as ordens não executadas, o que é crítico quando não é possível executar o ciclo de arbitragem completo. A execução incompleta do ciclo pode levar a uma exposição cambial indesejada. O sistema implementa o princípio 'tudo ou nada' na execução dos ciclos de arbitragem. Se qualquer ordem do ciclo não puder ser enviada, todas as ordens enviadas anteriormente são canceladas por meio da função CancelPendingOrders().

void CancelPendingOrders() {
   int cancelled = 0;
   
   for(int i = OrdersTotal() - 1; i >= 0; i--) {
      if(OrderSelect(i, SELECT_BY_POS) && OrderMagicNumber() == 999) {
         if(trade.OrderDelete(OrderTicket())) {
            cancelled++;
         }
      }
   }
   
   PrintFormat("Cancelled %d pending orders", cancelled);
}

void CheckPendingOrdersTimeout() {
   datetime current_time = TimeCurrent();
   
   for(int i = OrdersTotal() - 1; i >= 0; i--) {
      if(OrderSelect(i, SELECT_BY_POS) && OrderMagicNumber() == 999) {
         // Отмена ордеров старше 30 секунд
         if(current_time - OrderOpenTime() > 30) {
            trade.OrderDelete(OrderTicket());
            PrintFormat("Cancelled expired order: %s", OrderSymbol());
         }
      }
   }
}

Para ordens limitadas, o sistema aplica uma melhoria de preço de PriceImprovementPoints pontos. Em operações de compra, posiciona o preço abaixo do bid atual pela quantidade especificada de pontos; em operações de venda, posiciona-o acima do ask atual.

price = NormalizeDouble(
   edge.is_buy ? tick.bid - PriceImprovementPoints * GetPoint(edge.symbol) 
               : tick.ask + PriceImprovementPoints * GetPoint(edge.symbol), 
   digits);

Essa melhoria de preço aumenta a probabilidade de a ordem ser executada a um preço melhor, o que eleva ligeiramente a lucratividade da arbitragem. No entanto, uma melhoria excessiva pode reduzir a probabilidade de execução.

Antes de enviar a ordem, o sistema verifica se o preço é válido e se está em conformidade com o tamanho mínimo do tick. A função GetPoint() processa corretamente diferentes tipos de cotações, incluindo preços de 3 e 5 dígitos.

double GetPoint(string symbol) {
   return SymbolInfoDouble(symbol, SYMBOL_POINT);
}



Adaptação às condições de mercado

Tratamento das condições de mercado
O sistema se adapta automaticamente às condições variáveis de mercado recriando periodicamente o grafo. A cada análise, ele reconstrói o grafo com base nas cotações atuais, mantém os dados atualizados e considera as mudanças nos spreads.

A função FindAllArbitrageCycles() integra os resultados dos dois algoritmos de busca (Floyd-Warshall e DFS) e busca a cobertura máxima das possíveis oportunidades de arbitragem.

Para otimizar o sistema, as principais frentes são limitar o tamanho do grafo usando apenas pares de moedas líquidos, filtrar previamente os pares com spreads elevados, armazenar resultados em cache para reutilizá-los e distribuir os cálculos entre os recursos disponíveis.

A análise de desempenho do EA de arbitragem revela vários gargalos críticos que ainda exigem otimização. Os principais componentes que mais demandam recursos computacionais são o algoritmo Floyd-Warshall, com complexidade temporal O(n³), e a função DFS, com complexidade exponencial no pior caso.

O algoritmo Floyd-Warshall executa vertex_count³ operações, o que, com 25 moedas, corresponde a 15625 iterações. Cada iteração inclui operações de ponto flutuante e verificações condicionais, o que pode impor uma carga significativa ao processador.

Vejamos um teste do EA em ticks, com emulação de latência de 80 ms, no período de 1 de julho a 9 de setembro de 2025:

O sistema apresentou resultados ruins: apesar de 100% dos ciclos terem sido fechados com lucro, um dos ciclos derrubou drasticamente o equity até um drawdown de -48%. Sim, conseguimos obter lucratividade logo na primeira tentativa, e o lucro no período de teste foi de cerca de 39%, de modo que a relação lucro/drawdown ainda é ruim. O coeficiente de Sharpe, por sua vez, ficou acima de 1.6, o que é um valor relativamente bom.

Ainda assim, neste artigo, pelo menos um objetivo foi alcançado: conseguimos criar um algoritmo de arbitragem que encontra combinações de arbitragem continuamente, e os 223 trades confirmam isso.

Integração de machine learning e outras melhorias

Uma frente promissora de desenvolvimento é a integração de algoritmos de machine learning para prever a probabilidade de execução bem-sucedida dos ciclos de arbitragem. O modelo pode analisar dados históricos sobre execuções bem-sucedidas em função dos tamanhos dos spreads, do horário do dia, da volatilidade e de outros fatores.

O sistema pode ser expandido para buscar oportunidades de arbitragem entre diferentes plataformas de negociação e corretoras. Isso exige criar um agregador próprio de liquidez a partir de várias corretoras e torna mais complexa a lógica para gerenciar posições.

A adaptação do sistema para mercados de criptomoedas abre novas oportunidades graças à maior volatilidade e ao maior número de pares negociados. No entanto, isso exige modificar os algoritmos para operar em mercados 24/7 e levar em conta as especificidades das transações em blockchain.


Conclusão

O EA de arbitragem apresentado mostra como aplicar conceitos matemáticos complexos em um sistema de negociação em tempo real. O sistema combina algoritmos de grafos, métodos numéricos e lógica de negociação em uma solução única capaz de detectar e aproveitar automaticamente oportunidades de arbitragem no mercado de moedas.

As principais conquistas do projeto são: o sistema constrói e analisa automaticamente o grafo das relações cambiais, implementa dois algoritmos de busca complementares (Floyd-Warshall e DFS), busca manter exposição cambial zero por meio do balanceamento preciso dos lotes, gerencia riscos com um mecanismo inteligente de preço médio e adapta as operações de negociação às condições de mercado.

Os testes do sistema em ambiente demo mostraram sua capacidade de processar grafos com 25+ moedas e 500+ arestas, com tempo de análise inferior a 100 milissegundos em hardware moderno. O sistema demonstra estabilidade em operação contínua por várias semanas, sem degradação de desempenho.

A frequência de detecção de oportunidades de arbitragem depende significativamente das condições de mercado e dos parâmetros de filtragem. Em períodos de maior volatilidade, o sistema pode detectar de 5 a 10 oportunidades potenciais por hora, a maioria delas com lucratividade mínima de 0.05-0.15%.

O EA de arbitragem apresentado demonstra o potencial do MQL5 para criar sistemas de negociação complexos. O sistema combina a precisão matemática dos algoritmos de grafos com aspectos práticos do trading, como gerenciar riscos e se adaptar às condições de mercado.

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

Arquivos anexados |
Redes neurais no trading: uma visão unificada sobre espaço e tempo (Extralonger) Redes neurais no trading: uma visão unificada sobre espaço e tempo (Extralonger)
O framework Extralonger demonstra uma abordagem para integrar fatores espaciais e temporais em um único modelo, permitindo considerar simultaneamente padrões locais e ciclos de longo prazo. Essa arquitetura torna a previsão de séries temporais mais robusta ao ruído de mercado e abre a possibilidade de analisar dados em diferentes horizontes. Neste artigo, examinamos em detalhes como implementar essas ideias na prática com OpenCL e MQL5.
Redes neurais em trading: modelo de difusão adaptativa em grafos (Conclusão) Redes neurais em trading: modelo de difusão adaptativa em grafos (Conclusão)
Neste artigo, concluímos a construção do framework SAGDFN em MQL5, apresentando um balanço do desenvolvimento e demonstrando os resultados de seu teste prático. Vamos reunir os módulos implementados anteriormente em um único sistema, mostrar os pontos fortes da abordagem, apontar suas vulnerabilidades e discutir possíveis caminhos de aprimoramento.
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.
Robô de negociação baseado em um modelo de linguagem GPT Robô de negociação baseado em um modelo de linguagem GPT
Este artigo apresenta a implementação completa do TimeGPT, uma arquitetura especializada baseada no Transformer para a previsão de séries temporais financeiras na plataforma MetaTrader 5. Ele aborda a adaptação do mecanismo de atenção para dados financeiros, a tokenização seletiva das variações de preço, otimizações orientadas ao hardware e técnicas avançadas de treinamento. O artigo inclui resultados de testes práticos, que demonstraram uma precisão de previsão de 87% em um horizonte de 24 barras, com um tempo de treinamento de 15 minutos na CPU. É apresentado um EA pronto para uso com reajuste automático do modelo.