English Русский Português
preview
Trading algorítmico de arbitraje basado en la teoría de grafos

Trading algorítmico de arbitraje basado en la teoría de grafos

MetaTrader 5Integración |
11 0
Yevgeniy Koshtenko
Yevgeniy Koshtenko

Introducción

El trading de arbitraje es una de las estrategias más interesantes y técnicamente complejas de los mercados financieros. Esta estrategia se basa en el aprovechamiento de las divergencias temporales en los precios entre distintos instrumentos financieros o mercados para obtener beneficios con un riesgo mínimo. En el contexto del mercado de divisas Forex, el arbitraje consiste en la búsqueda y el aprovechamiento de rutas cíclicas de conversión de divisas, en las que la divisa inicial y la final coinciden, y el tipo de cambio resultante genera beneficios una vez tenidos en cuenta todos los costes de transacción.

El principio del arbitraje consiste en que el tráder compra y vende simultáneamente instrumentos financieros relacionados a precios diferentes, obteniendo la diferencia como beneficio. Por ejemplo, un arbitraje triangular clásico puede incluir el ciclo USD → EUR → GBP → USD. Si el producto de los tipos de cambio en esta cadena supera la unidad, incluso tras deducir los spreads y las comisiones, dicho ciclo resulta rentable.

Es importante tener en cuenta que las oportunidades de arbitraje en los mercados financieros actuales son extremadamente escasas y de corta duración. Esto se debe a la alta eficiencia de los mercados, a la existencia del trading algorítmico y a la rapidez en la transmisión de la información. No obstante, el uso de tecnologías modernas y de algoritmos correctamente configurados puede permitir detectar y aprovechar esas oportunidades.

En este artículo analizaremos la creación de un asesor experto plenamente funcional en lenguaje MQL5, capaz de detectar automáticamente oportunidades de arbitraje, calcular los tamaños óptimos de las posiciones y gestionar los riesgos mediante un sistema de promediado de posiciones.


Fundamentos teóricos del arbitraje

Representación en grafos del mercado de divisas

El arbitraje cambiario puede representarse de forma elegante como el problema de buscar ciclos rentables en un grafo ponderado orientado. En este modelo, cada divisa (USD, EUR, GBP, JPY, etc.) se representa mediante un vértice del grafo, mientras que los pares de divisas (EURUSD, GBPUSD, USDJPY) se representan mediante aristas con los pesos correspondientes.

Los pesos de las aristas se determinan de la siguiente manera: para una operación de compra (paso de la divisa base a la divisa cotizada), el peso es igual a 1/ask_price; para una operación de venta (paso de la divisa cotizada a la divisa base), el peso es igual a bid_price. Un ciclo de arbitraje rentable es una ruta cerrada en un grafo en la que el producto de los pesos de todas las aristas supera la unidad tras deducir los costes de transacción.

Fórmula matemática del beneficio

El beneficio del ciclo de arbitraje se calcula mediante la siguiente fórmula:

Beneficio = w₁ × w₂ × ... × wₙ - 1 - Spread

Donde wᵢ es el peso de la i-ésima arista del ciclo, y «Spread» es el coste relativo total de los spreads de todas las operaciones. El spread de cada par de divisas se calcula como un valor relativo: (ask - bid) / bid.

Condición de exposición nula

Un requisito fundamental para el auténtico arbitraje es garantizar una exposición cambiaria nula. Esto significa que el volumen total de cada divisa en la cartera, una vez ejecutadas todas las operaciones del ciclo, debe ser igual a cero. Matemáticamente, esto se expresa mediante un sistema de ecuaciones lineales, en el que las compras de divisas se compensan con sus ventas.

Para lograr una exposición nula, es necesario calcular correctamente el tamaño de los lotes para cada operación del ciclo, teniendo en cuenta las especificaciones que el bróker establece para cada par de divisas.


Arquitectura del asesor experto de arbitraje

Enfoque para el diseño del sistema

El desarrollo de un asesor experto de arbitraje requiere un enfoque sistemático y una planificación minuciosa de la arquitectura. El sistema se basa en los principios de modularidad, escalabilidad y tolerancia a fallos. La idea principal consiste en crear componentes independientes, cada uno de los cuales se encarga de una funcionalidad concreta y puede modificarse sin que ello afecte al resto del sistema.

El módulo de construcción del grafo se encarga de crear un modelo matemático del mercado de divisas a partir de las cotizaciones actuales. Este componente debe procesar los datos del bróker, filtrar los instrumentos según los criterios establecidos y crear la estructura del grafo en memoria. Se presta especial atención al rendimiento, ya que el grafo debe reconstruirse en tiempo real a medida que cambian las cotizaciones.

El módulo algorítmico incluye la implementación de dos enfoques complementarios: el algoritmo de Floyd-Warshall para la búsqueda de rutas óptimas y el método de búsqueda en profundidad (DFS) para un análisis exhaustivo de todos los ciclos posibles. Este enfoque dual garantiza tanto la eficiencia de la búsqueda como una cobertura completa de las posibles oportunidades de arbitraje.

El módulo de balanceo de posiciones resuelve el problema matemático de garantizar una exposición cambiaria nula. Aquí se implementan algoritmos de cálculo del tamaño de los lotes teniendo en cuenta las especificaciones del bróker, incluidos los tamaños mínimos de los lotes, los pasos de variación del volumen y los requisitos de margen.

El módulo de trading gestiona la ejecución de las operaciones, incluida la colocación de órdenes, el seguimiento de su estado y la gestión de los errores de ejecución. Una característica importante es la implementación de un sistema de estrategias de contingencia para los casos en que no todas las órdenes del ciclo puedan ejecutarse simultáneamente.

El módulo de gestión de riesgos aplica estrategias avanzadas, entre las que se incluyen el promediado de posiciones, la gestión dinámica del tamaño de las posiciones y los sistemas de cierre de emergencia en caso de que se superen los límites de pérdidas.

Diseño de estructuras básicas

La implementación eficaz de un asesor experto de arbitraje comienza con un diseño adecuado de las estructuras de datos. La estructura «Edge» representa una arista del grafo y contiene toda la información necesaria para ejecutar una operación de trading. Los campos «from» y «to» definen la dirección del intercambio de divisas; «weight» contiene el tipo de cambio, y «spread», el coste relativo de la transacción.

#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;           // Vértice de origen
   int to;             // Vértice de destino
   double weight;      // Peso de la arista (tipo de cambio)
   double spread;      // Spread relativo
   string symbol;      // Nombre del par de divisas
   bool is_buy;        // Tipo de operación (compra/venta)
};

struct Vertex {
   string name;        // Nombre de la divisa (USD, EUR, etc.)
};

struct ArbitragePath {
   Edge path_edges[MAX_CYCLE_LENGTH];      // Aristas de la ruta
   int path_vertices[MAX_CYCLE_LENGTH + 1]; // Vértices de la ruta
   int length;                              // Longitud de la ruta
   double total_rate;                       // Tipo de cambio total
   double total_spread;                     // Spread total
   double net_profit;                       // Beneficio neto
   string description;                      // Descripción de la ruta
};

Los arrays globales para almacenar el grafo se asignan de forma estática para garantizar el máximo rendimiento. Las constantes MAX_VERTICES y MAX_EDGES determinan los tamaños máximos de las estructuras y deben equilibrarse entre las necesidades de memoria y el rendimiento.

Las matrices «dist» y «spread_matrix» se utilizan en el algoritmo de Floyd-Warshall y tienen unas dimensiones de MAX_VERTICES × MAX_VERTICES. La matriz `next_vertex` es necesaria para la reconstrucción de rutas. Estas estructuras requieren una gran cantidad de memoria, pero proporcionan acceso a los datos con complejidad O(1).

El array «all_arbitrage_paths» almacena todas las oportunidades de arbitraje encontradas y permite ordenarlas por rentabilidad. El tamaño del array (1 000 elementos) se ha seleccionado tras realizar pruebas prácticas y puede ajustarse en función de los requisitos del sistema.

Se ha prestado especial atención a la optimización de las operaciones con arrays. Todos los ciclos críticos utilizan direccionamiento directo en lugar de funciones de acceso, lo que minimiza la sobrecarga y mejora el rendimiento de los algoritmos de búsqueda de arbitraje.

// Parámetros del asesor experto
extern double LotSize = 0.1;           // Tamaño máximo del lote
extern double MinProfit = 0.0005;      // Beneficio mínimo (0,05 %)
extern int MaxSpreadPoints = 25;       // Spread máximo en puntos
extern int MaxPathLength = 4;          // Longitud máxima del ciclo
extern int MaxAverages = 2;            // Número máximo de operaciones de promediado
extern double PriceImprovementPoints = 1.0; // Mejora del precio en puntos
extern bool ShowDetailedPaths = true;  // Mostrar rutas detalladas

// Arrays para el grafo
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 búsqueda de arbitraje

Algoritmo de construcción del grafo

La función BuildGraph() es un componente clave del sistema, encargado de crear el modelo matemático del mercado de divisas. El proceso comienza con la inicialización de todas las estructuras de datos y el establecimiento de los valores iniciales de las matrices de distancias y de spreads.

void BuildGraph() {
   vertex_count = 0;
   edge_count = 0;
   
   // Inicialización de las matrices de distancias y de spreads
   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)) {
         // Cálculo del spread en puntos y del spread relativo
         double point = GetPoint(symbols[i]);
         double spread_points = (tick.ask - tick.bid) / point;
         double spread_cost = (tick.ask - tick.bid) / tick.bid;
         
         // Filtrado por spread máximo
         if(spread_points <= MaxSpreadPoints) {
            string base = StringSubstr(symbols[i], 0, 3);
            string quote = StringSubstr(symbols[i], 3, 3);
            
            // Adición de aristas para la compra y la venta
            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);
}

Los elementos diagonales de la matriz de distancias se establecen en 1,0, lo que corresponde al intercambio idéntico de una divisa por sí misma con coste cero. El resto de los elementos se inicializan con el valor INF (infinito), lo que indica que no existe una conexión directa entre las divisas.

El ciclo principal procesa un array de símbolos de pares de divisas y obtiene las cotizaciones actuales mediante SymbolInfoTick(). Para cada par se calcula el spread en puntos y el spread relativo. Los pares con un spread excesivamente alto se excluyen del análisis para mejorar la calidad de las oportunidades detectadas.

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

La función AddEdge() permite añadir aristas al grafo, creando automáticamente nuevos vértices cuando sea necesario. La función de adición de vértices ( GetOrAddVertexIndex ) utiliza una búsqueda lineal para comprobar si existe una divisa en el grafo, lo que garantiza la unicidad de los vértices.

Cada par de divisas genera dos aristas: una para la compra (paso de la divisa base a la divisa cotizada) y otra para la venta (paso inverso). El peso de la arista para la compra es igual a 1/ask, lo que refleja la cantidad de la divisa cotizada que se obtiene por cada unidad de la divisa base. El peso de la arista para la venta es igual a bid, lo que indica la cantidad de divisa base que se obtiene por unidad de divisa cotizada.

Además, las aristas se almacenan en la matriz edge_matrix para permitir un acceso rápido durante la reconstrucción de rutas. Esta optimización es fundamental para el rendimiento del algoritmo de 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) {
   // Búsqueda de un vértice existente
   for(int i=0; i<vertex_count; i++) {
      if(vertices[i].name == currency) return i;
   }
   
   // Adición de un nuevo vértice
   if(vertex_count < MAX_VERTICES) {
      vertices[vertex_count].name = currency;
      return vertex_count++;
   }
   return -1;
}

El sistema permite operar con distintos tipos de pares de divisas, incluidos los pares exóticos y los instrumentos con un número de decimales no estándar. La función GetPoint() procesa correctamente tanto las cotizaciones de 4 dígitos como las de 5 dígitos, lo que garantiza la compatibilidad con distintos brókeres.

Para los pares con JPY se aplica una lógica especial de cálculo de spreads, que tiene en cuenta que tradicionalmente tienen menos decimales. Esto evita que se excluyan erróneamente estos pares del análisis y mejora la calidad de las oportunidades de arbitraje detectadas.

Fundamentos matemáticos del algoritmo de Floyd–Warshall

El algoritmo de Floyd-Warshall, en el contexto del arbitraje de divisas, requiere una modificación sustancial de su versión clásica. El algoritmo estándar busca las rutas más cortas minimizando la suma de los pesos de las aristas, pero para el arbitraje necesitamos maximizar el producto de los tipos de cambio.

La modificación clave consiste en sustituir la operación de suma por la de multiplicación y la búsqueda del mínimo por la del máximo. Sin embargo, no basta con sustituir simplemente las operaciones: también es necesario tener en cuenta la acumulación de spreads y evitar la inestabilidad numérica al trabajar con productos.

void FloydWarshall() {
   // Inicialización de matrices
   for(int i=0; i<vertex_count; i++) {
      for(int j=0; j<vertex_count; j++) {
         if(i == j) {
            dist[i][j] = 1.0;           // Elementos diagonales = 1
            spread_matrix[i][j] = 0.0;  // Spread cero para una misma divisa
         } else {
            dist[i][j] = INF;           // Infinito para pares inalcanzables
            spread_matrix[i][j] = INF;
         }
         next_vertex[i][j] = -1;        // Matriz para la reconstrucción de rutas
      }
   }
   
   // Relleno de aristas directas
   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) {
         // Selección del mejor tipo de cambio para este par de divisas
         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;
         }
      }
   }
   
   // Bucle principal de 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];
               
               // Actualizar la ruta si ofrece un mejor tipo de cambio y el spread es aceptable
               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");
}

Al trabajar con productos de tipos de cambio, surgen cuestiones relacionadas con la estabilidad numérica. Los productos pueden llegar a ser muy grandes o muy pequeños, lo que provoca una pérdida de precisión. Para resolver este problema, se ha introducido una condición adicional para la comprobación de los spreads.

La condición new_spread < spread_matrix[i][j] * 2 evita que se acumulen spreads excesivamente altos, que podrían reducir a cero los beneficios potenciales. El coeficiente 2 se ha elegido de forma empírica y puede ajustarse en función de las condiciones del mercado.

La matriz next_vertex permite reconstruir las rutas encontradas. Al actualizar la distancia entre los vértices i y j a través del vértice intermedio k, almacenamos la información de que el siguiente vértice en la ruta de i a j coincide con el siguiente vértice en la ruta de i a k.

Reconstrucción de rutas de arbitraje

La función ReconstructPath() utiliza la matriz next_vertex para reconstruir la secuencia de aristas que conforman el ciclo de arbitraje encontrado. El proceso comienza en el vértice inicial y avanza sucesivamente hacia los vértices siguientes hasta volver al punto de origen.

void FindArbitrageFromFloydWarshall() {
   for(int i=0; i<vertex_count; i++) {
      if(dist[i][i] > 1.0) {  // Se ha encontrado un ciclo rentable
         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);
         }
      }
   }
}

Es fundamental realizar una correspondencia correcta entre las rutas encontradas y las aristas originales del grafo. Dado que entre dos divisas puede haber varias aristas (compra y venta), es necesario seleccionar la arista correcta que se corresponda con la ruta óptima encontrada.

El algoritmo de Floyd-Warshall tiene una complejidad temporal de O(n³), lo que puede suponer un cuello de botella cuando hay un gran número de divisas. Para optimizar el proceso, se aplican varias técnicas: filtrado previo de las divisas en función de su liquidez, uso de estructuras de datos más eficientes y paralelización de los cálculos allí donde sea posible.

Algoritmo de búsqueda en profundidad

El método de búsqueda en profundidad (DFS) complementa el de Floyd-Warshall, ya que garantiza un análisis exhaustivo de todos los ciclos de arbitraje posibles. A diferencia del algoritmo de Floyd-Warshall, que encuentra rutas óptimas entre pares de vértices, el DFS puede detectar ciclos alternativos que pueden resultar rentables en determinadas condiciones de mercado.

La función AdvancedDFS() lleva a cabo una búsqueda recursiva con múltiples optimizaciones. La idea principal consiste en recorrer sistemáticamente todas las rutas posibles a partir de cada divisa, comprobando la posibilidad de cerrar el ciclo y calculando la rentabilidad potencial.

void AdvancedDFS(int start, int current, double product, double spread_cost, 
                 Edge &path_edges[], int &path_vertices[], int depth) {
   // Limitación de la profundidad de búsqueda para evitar el desbordamiento de la pila
   if(depth >= MaxPathLength) return;
   
   // Iteración por todas las aristas salientes desde el vértice actual
   for(int i=0; i<edge_count; i++) {
      if(edges[i].from == current) {
         // Comprobación de la reutilización de un par de divisas
         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;  // Omisión de un par ya utilizado
         
         // Adición de una arista a la ruta actual
         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;
         
         // Comprobación del cierre del ciclo
         if(edges[i].to == start && depth >= 2) {
            double net_profit = new_product - 1.0 - new_spread;
            
            // Guardado de un ciclo rentable
            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;
               
               // Copia de la ruta
               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;
               
               // Creación de la descripción de la ruta
               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 {
            // Continuación de la búsqueda en profundidad
            AdvancedDFS(start, edges[i].to, new_product, new_spread, 
                       path_edges, path_vertices, depth + 1);
         }
      }
   }
}

Una característica de vital importancia de la implementación es evitar que se vuelva a utilizar el mismo instrumento de divisas dentro de un mismo ciclo. Esta restricción se debe a razones prácticas: la apertura simultánea de posiciones opuestas en un mismo instrumento puede generar problemas de ejecución y aumentar los costes de transacción.

La comprobación de symbol_used se lleva a cabo comparando el nombre del instrumento actual con todos los instrumentos ya incluidos en la ruta actual. Este enfoque garantiza que cada instrumento se utilice como máximo una vez dentro de un mismo ciclo de arbitraje.

Optimización de la profundidad de búsqueda

El parámetro MaxPathLength limita la longitud máxima de los ciclos analizados. Esta restricción tiene varios objetivos: evitar el desbordamiento de la pila en las llamadas recursivas, limitar el tiempo de ejecución del algoritmo y centrarse en oportunidades de arbitraje viables en la práctica.

Los estudios empíricos muestran que la mayoría de los ciclos de arbitraje útiles en la práctica tienen una longitud de entre 3 y 5 operaciones. Los ciclos más largos suelen tener spreads totales elevados, lo que reduce su rentabilidad hasta un nivel inaceptable.

Cálculo y almacenamiento de los ciclos encontrados

Cuando se detecta una arista de cierre (cuando el vértice de destino coincide con el inicial), el algoritmo calcula las métricas de rentabilidad del ciclo. El beneficio neto se calcula restando al producto de los tipos de cambio la unidad y el 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 se genera una descripción textual que incluye la secuencia de divisas y la dirección de las operaciones. Esta información es fundamental para analizar y depurar las oportunidades de arbitraje encontradas.

La descripción se crea mediante la concatenación de los nombres de las divisas con el separador «→», lo que ofrece una representación clara de la dirección del intercambio. Además, se almacena información sobre los tipos de operaciones (compra/venta) para cada arista del ciclo.


Balanceo de posiciones y gestión de riesgos

Fundamentos matemáticos del balanceo

Garantizar una exposición cambiaria nula es un requisito fundamental para el verdadero arbitraje. La función CalculateBalancedLots() resuelve un complejo problema matemático consistente en determinar los tamaños de las posiciones que garantizan un volumen neto nulo para cada divisa tras la ejecución de todas las operaciones del ciclo.

El algoritmo comienza por determinar la cantidad base (1 000 unidades), que representa el capital inicial en la divisa con la que se inicia el ciclo de arbitraje. Esta cantidad se transforma sucesivamente a través de cada arista del ciclo, considerando los tipos de cambio correspondientes.

Garantizar una exposición cambiaria nula es un aspecto de vital importancia en el trading de arbitraje. La exposición nula significa que, una vez ejecutadas todas las operaciones del ciclo de arbitraje, la posición neta en cada divisa debe ser igual a cero. Esto garantiza que los beneficios no dependan de las fluctuaciones posteriores de los tipos de cambio.

Para alcanzar una exposición nula, es necesario calcular minuciosamente el tamaño de cada posición del ciclo. El cálculo parte de un importe fijo en la divisa inicial y aplica sucesivamente los tipos de cambio para determinar los importes equivalentes en otras divisas.

La exposición total por cada divisa debe ser cercana a cero, con un margen de error admisible de 0,01 lotes.

Algoritmo de cálculo de lotes balanceados
bool CalculateBalancedLots(ArbitragePath &path, double &lots[]) {
   if(path.length == 0) return false;
   
   double base_amount = 1000.0;  // Importe inicial en la divisa base
   double current_amount = base_amount;
   
   // Obtención de las especificaciones del primer instrumento
   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);
   
   // Cálculo del tamaño del primer lote
   lots[0] = NormalizeDouble(base_amount / contract_size, 2);
   lots[0] = MathMax(min_lot, MathRound(lots[0] / step_lot) * step_lot);
   
   // Cálculo secuencial de los tamaños de lote para las demás operaciones
   for(int i = 1; i < path.length; i++) {
      // Aplicación del tipo de cambio de la arista anterior
      current_amount *= path.path_edges[i-1].weight;
      
      // Obtención de las especificaciones del instrumento actual
      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);
      
      // Cálculo y normalización del tamaño del lote
      lots[i] = NormalizeDouble(current_amount / contract_size, 2);
      lots[i] = MathMax(min_lot, MathRound(lots[i] / step_lot) * step_lot);
   }
   
   // Escalado de todos los lotes según el tamaño máximo permitido
   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;
}

Un aspecto de vital importancia es tener en cuenta las especificaciones de los instrumentos de trading de un bróker concreto. Cada instrumento tiene su propio tamaño de contrato (contract_size), su tamaño mínimo de lote (min_lot) y su paso de variación del lote (step_lot). Estos parámetros deben respetarse estrictamente para la colocación correcta de las órdenes.

El tamaño del lote para cada operación se calcula dividiendo el importe actual en la divisa correspondiente entre el tamaño del contrato. El valor obtenido se normaliza al tamaño de lote admisible más cercano, considerando el tamaño mínimo y el paso de variación.

Una vez calculados todos los lotes, el sistema comprueba que el lote máximo no supere el límite establecido en LotSize. Si se detecta un exceso, todos los lotes se reducen proporcionalmente, conservando sus proporciones relativas.

Este escalado es fundamental para la gestión de riesgos, ya que permite limitar la exposición máxima en cualquier instrumento concreto. Sin embargo, es necesario asegurarse de que, tras el escalado, todos los lotes sigan cumpliendo los requisitos mínimos del bróker.

Una función de comprobación adicional (que no aparece en el código básico, pero que es de vital importancia) debe verificar que los lotes calculados garanticen realmente una exposición nula. Esta comprobación consiste en crear un mapa de posiciones por divisa y sumar los volúmenes de compra y venta de cada divisa.

Verificación de la exposición cambiaria:

bool VerifyZeroExposure(ArbitragePath &path, double lots[]) {
   // Creación de un mapa de posiciones por divisa
   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);
      
      // Actualización de la exposición de la divisa base
      AddExposure(currencies, exposures, currency_count, base, 
                 path.path_edges[i].is_buy ? lots[i] : -lots[i]);
      
      // Actualización de la exposición de la divisa cotizada
      AddExposure(currencies, exposures, currency_count, quote, 
                 path.path_edges[i].is_buy ? -lots[i] : lots[i]);
   }
   
   // Comprobación de la proximidad a cero para todas las divisas
   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;
}
Estrategia de promediado de posiciones

El sistema de promediado de posiciones es un componente importante de la gestión de riesgos en un asesor experto de arbitraje. Cuando el mercado evoluciona en contra de una posición abierta, el asesor experto puede abrir una posición adicional en la misma dirección, lo que reduce el precio medio de entrada y, potencialmente, mejora el resultado global.

Sin embargo, el promediado de posiciones debe utilizarse con precaución, ya que aumenta el tamaño total de la posición y, por lo tanto, el riesgo potencial. Por ello, el sistema limita el número de operaciones de promediado de posiciones mediante el parámetro MaxAverages.

Implementación del sistema de promediado de posiciones
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;
}
Función de apertura de posiciones con promediado de posiciones:
bool OpenTradeFromEdge(Edge &edge, double balanced_lot) {
   // Comprobación de las órdenes pendientes existentes
   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) {
      // Modo de promediado de posiciones: colocación de una orden de mercado
      is_averaging = true;
      order_type = edge.is_buy ? ORDER_TYPE_BUY : ORDER_TYPE_SELL;
      price = 0;  // Precio de mercado
      
      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)) {
      // Entrada inicial: colocación de una orden límite con mejora del precio
      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;
   }
   
   // Colocación de una orden
   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;
   }
}

Seguimiento y gestión de posiciones:

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);
      
      // Cierre de ciclos de arbitraje rentables
      if(total_profit > 10.0) {  // Beneficio mínimo para el cierre
         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");
}
Estrategia de ejecución

La ejecución de una estrategia de arbitraje requiere la coordinación de múltiples órdenes en distintos pares de divisas. El asesor experto utiliza un enfoque combinado: órdenes límite para la entrada inicial con mejora del precio y órdenes de mercado para el promediado de posiciones.

Los principios clave de la ejecución incluyen la simultaneidad en la colocación de todas las órdenes del ciclo, el control de la ejecución con la cancelación de todo el grupo en caso de que falle cualquier orden y la gestión de riesgos mediante el seguimiento de la rentabilidad global de la cartera.

Función principal de ejecución
void ExecuteBestArbitrage() {
   if(arbitrage_path_count == 0) return;
   
   // Clasificación de las rutas según su rentabilidad
   SortArbitragePaths();
   
   ArbitragePath best = all_arbitrage_paths[0];
   PrintFormat("Executing best arbitrage: %s, profit: %.4f%%", 
              best.description, best.net_profit*100);
   
   // Cálculo de los lotes balanceados
   double balanced_lots[MAX_CYCLE_LENGTH];
   if(!CalculateBalancedLots(best, balanced_lots)) {
      Print("Failed to calculate balanced lots");
      return;
   }
   
   // Verificación de la exposición cambiaria
   if(!VerifyZeroExposure(best, balanced_lots)) {
      Print("Non-zero exposure detected, skipping execution");
      return;
   }
   
   // Colocación de todas las órdenes del ciclo
   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;
      }
   }
   
   // Control de la correcta colocación
   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() {
   // Ordenación simple por burbuja en orden descendente de rentabilidad
   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 el promediado de posiciones se utilizan órdenes de mercado, lo que garantiza la ejecución, pero puede provocar deslizamiento. Para la entrada inicial se utilizan órdenes límite con mejora del precio, lo que aumenta la probabilidad de obtener un mejor precio de ejecución.

El sistema incluye mecanismos de control y cancelación de órdenes límite no ejecutadas. La función HasPendingOrder() comprueba si hay órdenes activas para un instrumento, lo que evita la duplicación de órdenes.

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

La función CancelPendingOrders() permite cancelar de forma centralizada todas las órdenes no ejecutadas, lo cual resulta fundamental cuando no es posible ejecutar el ciclo de arbitraje completo. Si no se completa el ciclo, podría producirse una exposición cambiaria no deseada. El sistema aplica el principio de «todo o nada» para la ejecución de los ciclos de arbitraje. Si no se puede colocar alguna orden del ciclo, todas las órdenes colocadas anteriormente se cancelan mediante la función 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) {
         // Cancelación de órdenes con más de 30 segundos de antigüedad
         if(current_time - OrderOpenTime() > 30) {
            trade.OrderDelete(OrderTicket());
            PrintFormat("Cancelled expired order: %s", OrderSymbol());
         }
      }
   }
}

En el caso de las órdenes límite, el sistema aplica una mejora del precio equivalente al valor de PriceImprovementPoints. Para comprar, el precio se fija por debajo del bid actual en el número de puntos indicado; para vender, por encima del ask actual.

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

Esta mejora del precio aumenta la probabilidad de ejecución de una orden a un mejor precio, lo que incrementa ligeramente la rentabilidad del arbitraje. Sin embargo, una mejora excesiva puede reducir la probabilidad de ejecución.

Antes de la colocación de una orden, el sistema comprueba la validez del precio y que este se ajuste al tamaño mínimo de tick. La función GetPoint() procesa correctamente distintos tipos de cotizaciones, incluidos los precios de 3 y 5 dígitos.

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



Adaptación a las condiciones del mercado

Procesamiento de las condiciones del mercado
El sistema se adapta automáticamente a las condiciones cambiantes del mercado mediante la reconstrucción periódica del grafo. En cada análisis, el grafo se vuelve a generar a partir de las cotizaciones actuales, lo que garantiza la actualidad de los datos y tiene en cuenta las variaciones de los spreads.

La función FindAllArbitrageCycles() integra los resultados de ambos algoritmos de búsqueda (Floyd-Warshall y DFS), lo que garantiza la máxima cobertura de las posibles oportunidades de arbitraje.

Las principales líneas de optimización son la limitación del tamaño del grafo mediante el uso exclusivo de pares de divisas líquidos, el filtrado previo para excluir los pares con spreads elevados, el almacenamiento en caché de los cálculos para guardar los resultados y poder reutilizarlos, y la distribución de los cálculos entre los recursos disponibles.

El análisis del rendimiento del asesor experto de arbitraje pone de manifiesto varios cuellos de botella críticos que aún deben optimizarse. Los principales consumidores de recursos computacionales son el algoritmo de Floyd-Warshall, con una complejidad temporal de O(n³), y la función DFS, con una complejidad exponencial en el peor de los casos.

El algoritmo de Floyd-Warshall realiza vertex_count³ operaciones, lo que, con 25 divisas, equivale a 15 625 iteraciones. Cada iteración incluye operaciones de coma flotante y comprobaciones de condiciones, lo que puede suponer una carga considerable para el procesador.

Analicemos la prueba del asesor experto en ticks con una emulación de retardo de 80 ms, correspondiente al periodo comprendido entre el 1 de julio y el 9 de septiembre de 2025:

El sistema dio malos resultados: a pesar de que el 100 % de los ciclos se cerraron con beneficios, uno de ellos desplomó la curva de equity hasta una caída del -48 %. Sí, conseguimos alcanzar la rentabilidad desde el primer intento, y el beneficio durante el periodo de prueba fue de alrededor del 39 %, por lo que la relación entre beneficio y caída sigue siendo mala. El coeficiente de Sharpe es superior a 1,6, lo que supone un valor relativamente bueno.

No obstante, en el marco de este artículo se ha cumplido al menos un objetivo: hemos logrado crear un algoritmo de arbitraje que encuentra constantemente ciclos de arbitraje; las 223 transacciones lo confirman.

Integración del aprendizaje automático y otras mejoras

Un ámbito prometedor de desarrollo es la integración de algoritmos de aprendizaje automático para predecir la probabilidad de que los ciclos de arbitraje se ejecuten con éxito. El modelo puede analizar datos históricos sobre el éxito de la ejecución en función del tamaño de los spreads, la hora del día, la volatilidad y otros factores.

El sistema puede ampliarse para buscar oportunidades de arbitraje entre diferentes plataformas de negociación y brókeres. Esto requiere integrar un agregador de liquidez propio con varios brókeres y hacer más compleja la lógica de gestión de posiciones.

La adaptación del sistema a los mercados de criptomonedas abre nuevas posibilidades gracias a una mayor volatilidad y a un mayor número de pares de negociación. Sin embargo, esto requiere modificar los algoritmos para que funcionen en mercados que operan las 24 horas del día, los 7 días de la semana, y tener en cuenta las particularidades de las transacciones en blockchain.


Conclusión

El asesor experto de arbitraje presentado muestra la aplicación práctica de conceptos matemáticos complejos en un sistema de negociación en tiempo real. El sistema integra con éxito algoritmos de grafos, métodos numéricos y lógica de negociación en una solución única, capaz de detectar y aprovechar automáticamente las oportunidades de arbitraje en el mercado de divisas.

Entre los logros clave del proyecto se incluyen la construcción y el análisis automáticos de un grafo de relaciones cambiarias, la implementación de dos algoritmos de búsqueda complementarios (Floyd-Warshall y DFS), la garantía de una exposición cambiaria nula mediante un balanceo preciso de los lotes, un sistema inteligente de gestión de riesgos con mecanismo de promediado de posiciones y una gestión adaptativa de las operaciones de negociación que tiene en cuenta las condiciones del mercado.

Las pruebas realizadas en el entorno de demostración han demostrado que el sistema es capaz de procesar grafos con más de 25 divisas y más de 500 aristas, con un tiempo de análisis inferior a 100 milisegundos en equipos modernos. El sistema demuestra estabilidad durante varias semanas de funcionamiento continuo sin degradación del rendimiento.

La frecuencia con la que se detectan oportunidades de arbitraje depende en gran medida de las condiciones del mercado y de los parámetros de filtrado. En periodos de mayor volatilidad, el sistema puede detectar entre 5 y 10 oportunidades potenciales por hora, la mayoría de las cuales tienen una rentabilidad mínima del 0,05-0,15 %.

El asesor experto de arbitraje que aquí se presenta muestra las posibilidades que ofrece MQL5 a la hora de crear sistemas de negociación complejos. El sistema combina la precisión matemática de los algoritmos de grafos con aspectos prácticos del trading, como la gestión de riesgos y la adaptación a las condiciones del mercado.

Traducción del ruso hecha por MetaQuotes Ltd.
Artículo original: https://www.mql5.com/ru/articles/19462

Archivos adjuntos |
Utilizando redes neuronales en MetaTrader Utilizando redes neuronales en MetaTrader
En el artículo se muestra la aplicación de las redes neuronales en los programas de MQL, usando la biblioteca de libre difusión FANN. Usando como ejemplo una estrategia que utiliza el indicador MACD se ha construido un experto que usa el filtrado con red neuronal de las operaciones. Dicho filtrado ha mejorado las características del sistema comercial.
Desarrollo de un kit de herramientas para el análisis de la acción del precio (Parte 35): Entrenamiento y despliegue de modelos predictivos Desarrollo de un kit de herramientas para el análisis de la acción del precio (Parte 35): Entrenamiento y despliegue de modelos predictivos
Los datos históricos están lejos de ser «basura»: son la base de cualquier análisis de mercado sólido. En este artículo, te guiaremos paso a paso desde la recopilación de esos datos históricos hasta su uso para entrenar un modelo predictivo y, finalmente, la implementación de dicho modelo para realizar previsiones de precios en tiempo real. ¡Sigue leyendo para descubrir cómo hacerlo!
Particularidades del trabajo con números del tipo double en MQL4 Particularidades del trabajo con números del tipo double en MQL4
En estos apuntes hemos reunido consejos para resolver los errores más frecuentes al trabajar con números del tipo double en los programas en MQL4.
Redes neuronales en el trading: Modelo de difusión adaptativa sobre grafos (Final) Redes neuronales en el trading: Modelo de difusión adaptativa sobre grafos (Final)
En este artículo concluimos el trabajo de construcción del framework SAGDFN mediante MQL5, resumiendo el desarrollo y mostrando los resultados de sus pruebas prácticas. Integraremos los módulos implementados anteriormente en un único sistema, mostraremos los puntos fuertes de este enfoque, señalaremos sus vulnerabilidades y debatiremos posibles vías de mejora.