Dominando los registros (Parte 6): Guardando los registros en la base de datos
Introducción
Imagina un bullicioso mercado de transacciones digitales y magia financiera donde cada movimiento es rastreado, registrado y analizado meticulosamente para garantizar el éxito. ¿Y si no solo pudieras acceder a un historial de todas las decisiones y errores cometidos por tus asesores expertos, sino que además pudieras disponer de una potente herramienta para optimizar y perfeccionar estos bots en tiempo real? Bienvenidos a la primera parte de Dominando los registros (Parte 1): Conceptos fundamentales y primeros pasos en MQL5, donde hemos comenzado a crear una biblioteca de registro sofisticada y adaptada al desarrollo en MQL5.
Aquí, superamos las limitaciones de la interfaz de registro predeterminada de MetaTrader 5 para crear una solución de registro robusta, ajustable y dinámica que mejora el entorno de MQL5. Nuestro recorrido comenzó con la incorporación de requisitos fundamentales: una estructura Singleton fiable para una codificación coherente, registros de base de datos avanzados para un seguimiento de auditoría completo, flexibilidad de salida versátil, clasificación de los niveles de registro y formatos personalizables para las diversas necesidades de los proyectos.
Únase a nosotros mientras profundizamos en cómo puede transformar los datos brutos en información útil para comprender, controlar y mejorar el rendimiento de sus EA como nunca antes.
En este artículo, exploraremos todo, desde los conceptos fundamentales hasta la implementación práctica de un gestor de registros que escribe y consulta datos directamente desde una base de datos.
¿Qué son las bases de datos?
Los registros son el pulso de un sistema, capturando todo lo que sucede entre bastidores. Pero almacenarlos de forma eficiente es otra historia. Hasta ahora, hemos guardado los registros en archivos de texto, una solución sencilla y funcional en muchos casos. El problema surge cuando aumenta el volumen de datos: buscar información entre miles de líneas se convierte en una pesadilla para el rendimiento y la gestión.
Ahí es donde entran en juego las bases de datos. Ofrecen una forma estructurada y optimizada de almacenar, consultar y organizar la información. En lugar de revisar los archivos manualmente, podemos realizar consultas rápidas y encontrar exactamente lo que necesitamos. Pero, al fin y al cabo, ¿qué es una base de datos y por qué es tan esencial?
La estructura de una base de datos
Una base de datos funciona como un sistema de almacenamiento inteligente, donde los datos se organizan lógicamente para facilitar las búsquedas y manipulaciones. Imagínelo como un archivo bien catalogado, donde cada dato tiene su lugar definido. En el contexto de los registros, en lugar de guardar los datos en archivos dispersos, podemos almacenarlos de forma estructurada, filtrándolos rápidamente por fecha, tipo de error o cualquier otro criterio relevante.
Para comprenderlo mejor, vamos a desglosar la estructura de una base de datos en sus tres componentes fundamentales: tablas, columnas y filas.
-
Tablas: La base de la base de datos, una tabla funciona como una hoja de cálculo, donde agrupamos datos relacionados. En el caso de los registros, podríamos tener una tabla llamada "logs", dedicada exclusivamente a almacenar estos registros.
Cada tabla está diseñada para un tipo de datos específico, lo que garantiza la organización y la eficiencia en el acceso a la información.
Columnas: Los campos de datos; dentro de una tabla hay columnas, que representan las diferentes categorías de información almacenadas. Cada columna equivale a un campo de datos y define un tipo específico de información. Por ejemplo, en una tabla de logs, podemos tener columnas como:
- ID → Identificador único de registro
- Marca de tiempo → Fecha y hora del registro
- Nivel → Nivel de registro (DEPURACIÓN, INFORMACIÓN, ERROR...)
- Mensaje → Mensaje de registro
- Fuente → Origen del registro (qué sistema o módulo generó el registro)
Cada columna tiene una función bien definida. Por ejemplo, la marca de tiempo almacena fechas y horas, mientras que el mensaje contiene texto. Esta estructura evita redundancias y mejora el rendimiento de la búsqueda.
-
Filas: Los registros almacenados; si las columnas definen qué información se va a almacenar, las filas representan los registros individuales de la tabla. Cada fila contiene un conjunto completo de valores que rellenan las columnas correspondientes. Vea un ejemplo práctico de una tabla de logs:
ID Marca de tiempo Nivel Mensaje Fuente 1 2025-02-12 10:15 DEPURACIÓN Valor calculado del indicador RSI: 72,56 Indicadores 2 2025-02-12 10:16 INFORMACIÓN Orden de compra enviada correctamente Gestión de órdenes 3 2025-02-12 10:17 ALERTA Stop Loss ajustado al nivel de equilibrio Gestión de riesgos 4 2025-02-12 10:18 ERROR No se pudo enviar la orden de venta Gestión de órdenes 5 2025-02-12 10:19 FATAL Error al inicializar el EA: Configuración no válida Inicialización Cada línea constituye un registro individual que describe un evento específico.
Ahora que comprendemos la estructura de la base de datos, podemos explorar cómo aplicarla en la práctica dentro de MQL5 para almacenar y consultar registros de manera eficiente. Veámoslo en acción.
Bases de datos en MQL5
MQL5 permite almacenar y recuperar datos de forma estructurada, pero su compatibilidad con bases de datos tiene algunas peculiaridades que conviene comprender antes de pasar a la implementación.
A diferencia de los lenguajes destinados a aplicaciones web o corporativas, MQL5 no ofrece compatibilidad nativa con bases de datos relacionales robustas como MySQL o PostgreSQL. ¡Pero eso no significa que estemos obligados a usar archivos de texto! Podemos sortear esta limitación de dos maneras:
Utilizando SQLite, una base de datos ligera basada en archivos que es compatible de forma nativa con MQL5 (véanse las funciones de base de datos en MQL5), o estableciendo conexiones externas a través de API, lo que permite la integración con bases de datos más potentes. Para nuestro propósito de almacenar y consultar registros de manera eficiente, SQLite es la opción ideal. Es sencillo, rápido y no requiere un servidor dedicado, lo que lo hace perfecto para lo que necesitamos. Antes de pasar a la implementación, comprendamos mejor las características de una base de datos basada en un archivo .sqlite.
- Ventajas
- No requiere servidor: SQLite es una base de datos «integrada», lo que significa que no requiere la instalación ni la configuración de un servidor.
- Listo para usar: Solo tienes que crear el archivo .sqlite y empezar a guardar datos.
- Lectura rápida: Dado que se almacena en un único archivo, SQLite puede ser extremadamente rápido a la hora de leer datos de tamaño pequeño a mediano.
- Baja latencia: En el caso de consultas sencillas, puede resultar más rápido que las bases de datos relacionales tradicionales.
- Alta compatibilidad: Compatible con varios lenguajes de programación
- Desventajas
- Riesgo de corrupción del archivo: Si el archivo está dañado, recuperar los datos puede resultar complicado.
- Copia de seguridad manual: La copia de seguridad debe realizarse copiando el archivo .sqlite, ya que SQLite no admite de forma nativa la replicación automática.
- No se adapta bien a grandes volúmenes: Para grandes volúmenes de datos y accesos simultáneos, SQLite no es la mejor opción. Pero, dado que nuestro objetivo es almacenar los registros localmente, esto no supondrá ningún problema.
Ahora que conocemos las posibilidades y limitaciones de las bases de datos en MQL5, podemos profundizar en las operaciones básicas que tendremos que implementar para almacenar y recuperar registros de forma eficaz.
Los conceptos básicos de las operaciones con bases de datos que necesitamos
Antes de implementar nuestro controlador, debemos comprender las operaciones básicas que vamos a utilizar en la base de datos. Estas operaciones incluyen la creación de tablas, la inserción de nuevos registros, la recuperación de datos y, en su caso, la eliminación o actualización de registros cuando sea necesario.
En el ámbito del registro de eventos, normalmente necesitamos almacenar información como la fecha y la hora, el nivel de registro, el mensaje y, en su caso, el nombre del archivo o del componente que generó la entrada. Para ello, debemos estructurar nuestra tabla de manera que facilite la realización de consultas rápidas y eficientes.
En primer lugar, vamos a crear un asesor experto (EA) de prueba sencillo llamado DatabaseTest.mq5 dentro de la carpeta Experts/Logify. Una vez creado el archivo, tendremos algo parecido a esto:
//+------------------------------------------------------------------+ //| DatabaseTest.mq5 | //| joaopedrodev | //| https://www.mql5.com/en/users/joaopedrodev | //+------------------------------------------------------------------+ #property copyright "joaopedrodev" #property link "https://www.mql5.com/en/users/joaopedrodev" #property version "1.00" //+------------------------------------------------------------------+ //| Import CLogify | //+------------------------------------------------------------------+ #include <Logify/Logify.mqh> //+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit() { //--- //--- return(INIT_SUCCEEDED); } //+------------------------------------------------------------------+
Creación y conexión a la base de datos
El primer paso es crear la base de datos y conectarse a ella. Para ello, utilizamos la función DatabaseOpen(), que admite dos parámetros:
- filename: Nombre del archivo respecto a la carpeta "MQL5\Files".
- flags : Combinación de banderas de la enumeración ENUM_DATABASE_OPEN_FLAGS. Estas banderas determinan cómo se accederá a la base de datos. Las banderas disponibles son:
- DATABASE_OPEN_READONLY - Acceso de solo lectura.
- DATABASE_OPEN_READWRITE - Permite leer y escribir.
- DATABASE_OPEN_CREATE - Crea la base de datos en el disco si no existe.
- DATABASE_OPEN_MEMORY - Crea una base de datos temporal en memoria.
- DATABASE_OPEN_COMMON - El archivo se almacenará en la carpeta común a todos los terminales.
Para nuestro ejemplo, utilizaremos DATABASE_OPEN_READWRITE | DATABASE_OPEN_CREATE. De esta forma, garantizamos que la base de datos se creará automáticamente si aún no existe, evitando comprobaciones manuales.
La función DatabaseOpen() devuelve un identificador de base de datos, que guardamos en una variable para utilizarlo en operaciones posteriores. Además, es imprescindible cerrar la conexión al finalizar su uso, lo cual hacemos mediante la función DatabaseClose().
Nuestro código ahora se ve así:
//+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit() { //--- Opening a database connection int dbHandle = DatabaseOpen(path,DATABASE_OPEN_READWRITE|DATABASE_OPEN_CREATE); if(dbHandle == INVALID_HANDLE) { Print("[ERROR] ["+TimeToString(TimeCurrent())+"] Database error (Code: "+IntegerToString(GetLastError())+")"); return(INIT_FAILED); } Print("Open database file"); //--- Closing database after use DatabaseClose(handle_db); Print("Closed database file"); return(INIT_SUCCEEDED); } //+------------------------------------------------------------------+
Ahora que hemos logrado abrir y cerrar la base de datos, es hora de estructurar los datos almacenados. Comencemos creando nuestra primera tabla: logs.
Crear una tabla
Pero antes de empezar a crear tablas sin ningún criterio, debemos comprobar si ya existen. Para ello, utilizamos la función DatabaseTableExists(). Si la tabla no existe en la base de datos, la creamos con un sencillo comando SQL. Hablando de SQL (Structured Query Language), este es el lenguaje que se utiliza para interactuar con las bases de datos, lo que permite insertar, consultar, modificar o eliminar datos. Piensa en SQL como una especie de "menú de restaurante" para bases de datos: haces un pedido (consulta SQL) y recibes exactamente lo que pediste, ¡siempre y cuando el pedido esté bien formulado, por supuesto!
Ahora, veremos esto en la práctica y estructuraremos nuestra tabla de registros, asegurándonos de que se cree correctamente siempre que sea necesario.
Para lo que nos ocupa, solo necesitamos conocer unos pocos comandos SQL; el primero de ellos sirve para crear una tabla:
CREATE TABLE {table_name} ({column_name} {type_data}, …); - {table_name}: Name of the table to be created.
- {column_name} {type_data}: Definition of the columns, where {type_data} indicates the data type (text, number, date, etc.).
Ahora utilizaremos la función DatabaseExecute() para ejecutar el comando de creación de la tabla. La estructura de la tabla se basará en la estructura MqlLogifyModel y contendrá los siguientes campos:
- id: Identificador único de la fila.
- formated: Mensaje con formato.
- levelname: Nombre del nivel de registro.
- msg: Mensaje original.
- args: Argumentos del mensaje.
- timestamp: Fecha y hora en formato numérico.
- date_time: Fecha y hora con formato.
- level: Nivel de gravedad del registro.
- origin: Fuente del registro.
- filename: Nombre del archivo de origen.
- function: Función en la que se generó el registro.
- line: Línea de código en la que se generó el registro.
Nuestro código ahora se ve así:
//+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit() { //--- Open the database connection int dbHandle = DatabaseOpen("db\\logs.sqlite", DATABASE_OPEN_READWRITE | DATABASE_OPEN_CREATE); if(dbHandle == INVALID_HANDLE) { Print("[ERROR] [" + TimeToString(TimeCurrent()) + "] Unable to open database (Error Code: " + IntegerToString(GetLastError()) + ")"); return(INIT_FAILED); } Print("[INFO] Database connection opened successfully"); //--- Create the 'logs' table if it does not exist if(!DatabaseTableExists(dbHandle, "logs")) { DatabaseExecute(dbHandle, "CREATE TABLE logs (" "id INTEGER PRIMARY KEY AUTOINCREMENT," // Auto-incrementing unique ID "formated TEXT," // Formatted log message "levelname TEXT," // Log level (INFO, ERROR, etc.) "msg TEXT," // Main log message "args TEXT," // Additional details "timestamp BIGINT," // Log event timestamp (Unix time) "date_time DATETIME,"// Human-readable date and time "level BIGINT," // Log level as an integer "origin TEXT," // Module or component name "filename TEXT," // Source file name "function TEXT," // Function where the log was recorded "line BIGINT);"); // Source code line number Print("[INFO] 'logs' table created successfully"); } //--- Close the database connection DatabaseClose(dbHandle); Print("[INFO] Database connection closed successfully"); return(INIT_SUCCEEDED); } //+------------------------------------------------------------------+
Con esto queda completado el proceso de creación de la base de datos y de la tabla «logs». Una vez creada la tabla, el archivo de la base de datos debería aparecer en la carpeta «Files» del explorador de archivos:

Al hacer clic en el archivo, si el MetaEditor es compatible con él, debería abrirse en una pantalla similar a esta:

Aquí tenemos una interfaz donde podemos ver los datos de la base de datos y ejecutar diferentes comandos SQL, como se muestra resaltado en rojo. Utilizaremos mucho esta función para visualizar los datos en el editor.
Cómo insertar datos en la base de datos
En SQL, el comando utilizado para insertar datos en una tabla es:
INSERT INTO {table_name} ({column}, ...) VALUES ({value}, ...) En el contexto de MQL5, esta afirmación se puede simplificar con la ayuda de funciones específicas que hacen que el proceso sea más intuitivo y menos propenso a errores. Las funciones principales que utilizará son:
- DatabasePrepare() - Esta función crea un identificador para la consulta SQL, preparándola para su posterior ejecución. Constituye el primer paso para que la base de datos interprete la consulta.
- DatabaseBind() - Con esta función, se asocian valores reales a los parámetros de la consulta. En el comando SQL, los valores se representan mediante marcadores de posición (por ejemplo, ?1, ?2, etc.), que se sustituirán por los datos proporcionados en el momento de la ejecución.
- DatabaseRead() - Se encarga de ejecutar la consulta preparada. En el caso de los comandos que no devuelven registros (como INSERT), esta función garantiza la ejecución de la instrucción y el paso al siguiente registro, si es necesario.
- DatabaseFinalize() - Una vez finalizado su uso, es importante liberar los recursos asociados a la consulta. Esta función cierra la consulta preparada anteriormente, evitando así fugas de memoria.
Al crear la consulta para la inserción de datos, podemos utilizar marcadores de posición para indicar dónde se insertarán los valores posteriormente. Veamos el siguiente ejemplo, que inserta un nuevo registro en la tabla «logs», siguiendo las columnas que hemos creado anteriormente:
INSERT INTO logs (formated, levelname, msg, args, timestamp, date_time, level, origin, filename, function, line) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11);
Ten en cuenta que se han incluido todos los campos de la tabla, excepto el campo «id», que es generado automáticamente por la base de datos. Además, los valores que se van a insertar se indican mediante ?1, ?2, etc.; cada marcador de posición corresponde a un índice que se utilizará posteriormente para asociar el valor real mediante la función DatabaseBind().
//--- Prepare SQL statement for inserting a log entry string sql = "INSERT INTO logs (formated, levelname, msg, args, timestamp, date_time, level, origin, filename, function, line) " "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11);"; int sqlRequest = DatabasePrepare(dbHandle, sql); if(sqlRequest == INVALID_HANDLE) { Print("[ERROR] Failed to prepare SQL statement for log insertion"); } //--- Bind values to the SQL statement DatabaseBind(sqlRequest, 0, "06:24:00 [INFO] Buy order sent successfully"); // Formatted log message DatabaseBind(sqlRequest, 1, "INFO"); // Log level name DatabaseBind(sqlRequest, 2, "Buy order sent successfully"); // Main log message DatabaseBind(sqlRequest, 3, "Symbol: EURUSD, Volume: 0.1"); // Additional details DatabaseBind(sqlRequest, 4, 1739471040); // Unix timestamp DatabaseBind(sqlRequest, 5, "2025.02.13 18:24:00"); // Readable date and time DatabaseBind(sqlRequest, 6, 1); // Log level as integer DatabaseBind(sqlRequest, 7, "Order Management"); // Module or component name DatabaseBind(sqlRequest, 8, "File.mq5"); // Source file name DatabaseBind(sqlRequest, 9, "OnInit"); // Function name DatabaseBind(sqlRequest, 10, 100); // Line numberUna vez asignados todos los valores, se utiliza la función DatabaseRead() para ejecutar la consulta preparada. Si la ejecución se realiza correctamente, se muestra un mensaje de confirmación; en caso contrario, se notifica un error:
//+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit() { //--- Open the database connection int dbHandle = DatabaseOpen("db\\logs.sqlite", DATABASE_OPEN_READWRITE | DATABASE_OPEN_CREATE); if(dbHandle == INVALID_HANDLE) { Print("[ERROR] [" + TimeToString(TimeCurrent()) + "] Unable to open database (Error Code: " + IntegerToString(GetLastError()) + ")"); return(INIT_FAILED); } Print("[INFO] Database connection opened successfully"); //--- Create the 'logs' table if it does not exist if(!DatabaseTableExists(dbHandle, "logs")) { DatabaseExecute(dbHandle, "CREATE TABLE logs (" "id INTEGER PRIMARY KEY AUTOINCREMENT," // Auto-incrementing unique ID "formated TEXT," // Formatted log message "levelname TEXT," // Log level (INFO, ERROR, etc.) "msg TEXT," // Main log message "args TEXT," // Additional details "timestamp BIGINT," // Log event timestamp (Unix time) "date_time DATETIME,"// Human-readable date and time "level BIGINT," // Log level as an integer "origin TEXT," // Module or component name "filename TEXT," // Source file name "function TEXT," // Function where the log was recorded "line BIGINT);"); // Source code line number Print("[INFO] 'logs' table created successfully"); } //--- Prepare SQL statement for inserting a log entry string sql = "INSERT INTO logs (formated, levelname, msg, args, timestamp, date_time, level, origin, filename, function, line) " "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11);"; int sqlRequest = DatabasePrepare(dbHandle, sql); if(sqlRequest == INVALID_HANDLE) { Print("[ERROR] Failed to prepare SQL statement for log insertion"); } //--- Bind values to the SQL statement DatabaseBind(sqlRequest, 0, "06:24:00 [INFO] Buy order sent successfully"); // Formatted log message DatabaseBind(sqlRequest, 1, "INFO"); // Log level name DatabaseBind(sqlRequest, 2, "Buy order sent successfully"); // Main log message DatabaseBind(sqlRequest, 3, "Symbol: EURUSD, Volume: 0.1"); // Additional details DatabaseBind(sqlRequest, 4, 1739471040); // Unix timestamp DatabaseBind(sqlRequest, 5, "2025.02.13 18:24:00"); // Readable date and time DatabaseBind(sqlRequest, 6, 1); // Log level as integer DatabaseBind(sqlRequest, 7, "Order Management"); // Module or component name DatabaseBind(sqlRequest, 8, "File.mq5"); // Source file name DatabaseBind(sqlRequest, 9, "OnInit"); // Function name DatabaseBind(sqlRequest, 10, 100); // Line number //--- Execute the SQL statement if(!DatabaseRead(sqlRequest)) { Print("[ERROR] SQL insertion request failed"); } else { Print("[INFO] Log entry inserted successfully"); } //--- Close the database connection DatabaseClose(dbHandle); Print("[INFO] Database connection closed successfully"); return(INIT_SUCCEEDED); } //+------------------------------------------------------------------+Al ejecutar este asesor experto, aparecerán los siguientes mensajes en la consola:
[INFO] Database file opened successfully [INFO] Table 'logs' created successfully [INFO] Log entry inserted successfully [INFO] Database file closed successfully
Además, al abrir la base de datos en el editor, podrás ver la tabla de registros con todos los datos introducidos, tal y como se muestra en la imagen siguiente:

Cómo leer datos de la base de datos
La lectura de datos de una base de datos implica un proceso muy similar a la inserción de registros, pero con el objetivo de recuperar información ya almacenada. En MQL5, el proceso básico para leer datos consiste en:
- Prepara la consulta SQL: Mediante la función DatabasePrepare(), se crea un identificador para la consulta que se va a ejecutar.
- Ejecuta la consulta: Con el identificador preparado, la función DatabaseRead() ejecuta la consulta y coloca el cursor en el primer registro del resultado.
- Extraer los datos: A partir del registro actual, se utilizan funciones específicas para obtener los valores de cada columna, según el tipo de datos esperado. Entre estas funciones se incluyen:
- DatabaseColumnText() - Obtiene el valor del campo del registro actual como cadena
- DatabaseColumnInteger() - Obtiene un valor de tipo int del registro actual
- DatabaseColumnLong() - Obtiene un valor de tipo «long» del registro actual
- DatabaseColumnDouble() - Obtiene un valor de tipo doble del registro actual
- DatabaseColumnBlob() - Obtiene el valor del campo del registro actual en forma de matriz
Con estos pasos, dispondrás de un proceso sencillo y eficaz para recuperar información y utilizarla según las necesidades de tu aplicación.
Por ejemplo, supongamos que quieres recuperar todos los registros de la tabla «logs». La consulta SQL para esta operación es bastante sencilla:
SELECT * FROM logs
Esta consulta selecciona todas las columnas de todos los registros de la tabla. En MQL5, utilizamos la función DatabasePrepare() para crear el identificador de consulta, tal y como hicimos al insertar datos.
Al final, el código queda así:
//+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit() { //--- Open the database connection int dbHandle = DatabaseOpen("db\\logs.sqlite", DATABASE_OPEN_READWRITE | DATABASE_OPEN_CREATE); if(dbHandle == INVALID_HANDLE) { Print("[ERROR] [" + TimeToString(TimeCurrent()) + "] Unable to open database (Error Code: " + IntegerToString(GetLastError()) + ")"); return INIT_FAILED; } Print("[INFO] Database connection opened successfully."); //--- Create the 'logs' table if it doesn't exist if(!DatabaseTableExists(dbHandle, "logs")) { string createTableSQL = "CREATE TABLE logs (" "id INTEGER PRIMARY KEY AUTOINCREMENT," // Auto-incrementing unique ID "formated TEXT," // Formatted log message "levelname TEXT," // Log level name (INFO, ERROR, etc.) "msg TEXT," // Main log message "args TEXT," // Additional arguments/details "timestamp BIGINT," // Timestamp of the log event "date_time DATETIME," // Human-readable date and time "level BIGINT," // Log level as an integer "origin TEXT," // Module or component name "filename TEXT," // Source file name "function TEXT," // Function where the log was recorded "line BIGINT);"; // Line number in the source code DatabaseExecute(dbHandle, createTableSQL); Print("[INFO] 'logs' table created successfully."); } //--- Prepare SQL statement to retrieve log entries string sqlQuery = "SELECT * FROM logs"; int sqlRequest = DatabasePrepare(dbHandle, sqlQuery); if(sqlRequest == INVALID_HANDLE) { Print("[ERROR] Failed to prepare SQL statement."); } //--- Execute the SQL statement if(!DatabaseRead(sqlRequest)) { Print("[ERROR] SQL query execution failed."); } else { Print("[INFO] SQL query executed successfully."); //--- Bind SQL query results to the log data model MqlLogifyModel logData; DatabaseColumnText(sqlRequest, 1, logData.formated); DatabaseColumnText(sqlRequest, 2, logData.levelname); DatabaseColumnText(sqlRequest, 3, logData.msg); DatabaseColumnText(sqlRequest, 4, logData.args); DatabaseColumnLong(sqlRequest, 5, logData.timestamp); string dateTimeStr; DatabaseColumnText(sqlRequest, 6, dateTimeStr); logData.date_time = StringToTime(dateTimeStr); DatabaseColumnInteger(sqlRequest, 7, logData.level); DatabaseColumnText(sqlRequest, 8, logData.origin); DatabaseColumnText(sqlRequest, 9, logData.filename); DatabaseColumnText(sqlRequest, 10, logData.function); DatabaseColumnLong(sqlRequest, 11, logData.line); Print("[INFO] Data retrieved: Formatted = ", logData.formated, " | Level = ", logData.level, " | Origin = ", logData.origin); } //--- Close the database connection DatabaseClose(dbHandle); Print("[INFO] Database connection closed successfully."); return INIT_SUCCEEDED; } //+------------------------------------------------------------------+
Bien, dicho esto, al ejecutar el código obtenemos este resultado
[INFO] Database file opened successfully [INFO] SQL request successfully [INFO] Data read! | Formated: 06:24:00 [INFO] Buy order sent successfully | Level: 1 | Origin: Order Management [INFO] Database file closed successfully
Teniendo en cuenta estas operaciones básicas, estamos listos para configurar nuestro gestor de base de datos. Vamos a preparar el entorno necesario para integrar la base de datos con nuestra biblioteca de registro de eventos.
Configuración del controlador de la base de datos
Para poder utilizar una base de datos para almacenar registros, necesitamos configurar correctamente nuestro controlador. Esto implica definir los atributos de la estructura de configuración, de forma similar a como lo hicimos con el gestor de archivos. Vamos a crear una estructura de configuración llamada “MqlLogifyHandleDatabaseConfig”, copiar esa estructura y realizar algunos cambios:
struct MqlLogifyHandleDatabaseConfig { string directory; // Directory for log files string base_filename; // Base file name ENUM_LOG_FILE_EXTENSION file_extension; // File extension type ENUM_LOG_ROTATION_MODE rotation_mode; // Rotation mode int messages_per_flush; // Messages before flushing uint codepage; // Encoding (e.g., UTF-8, ANSI) ulong max_file_size_mb; // Max file size in MB for rotation int max_file_count; // Max number of files before deletion //--- Default constructor MqlLogifyHandleDatabaseConfig(void) { directory = "logs"; // Default directory base_filename = "expert"; // Default base name file_extension = LOG_FILE_EXTENSION_LOG;// Default to .log extension rotation_mode = LOG_ROTATION_MODE_SIZE;// Default size-based rotation messages_per_flush = 100; // Default flush threshold codepage = CP_UTF8; // Default UTF-8 encoding max_file_size_mb = 5; // Default max file size in MB max_file_count = 10; // Default max file count } };
He marcado en rojo los atributos como rotación, tipo de archivo, número máximo de archivos, modo de codificación, entre otros, que se eliminarán, ya que no tienen sentido en el contexto de la base de datos. Con los atributos definidos, ajustaremos el método ValidityConfig(), al final el código se verá así:
//+------------------------------------------------------------------+ //| Struct: MqlLogifyHandleDatabaseConfig | //+------------------------------------------------------------------+ struct MqlLogifyHandleDatabaseConfig { string directory; // Directory for log files string base_filename; // Base file name int messages_per_flush; // Messages before flushing //--- Default constructor MqlLogifyHandleDatabaseConfig(void) { directory = "logs"; // Default directory base_filename = "expert"; // Default base name messages_per_flush = 100; // Default flush threshold } //--- Destructor ~MqlLogifyHandleDatabaseConfig(void) { } //--- Validate configuration bool ValidateConfig(string &error_message) { //--- Saves the return value bool is_valid = true; //--- Check if the directory is not empty if(directory == "") { directory = "logs"; error_message = "The directory cannot be empty."; is_valid = false; } //--- Check if the base filename is not empty if(base_filename == "") { base_filename = "expert"; error_message = "The base filename cannot be empty."; is_valid = false; } //--- Check if the number of messages per flush is positive if(messages_per_flush <= 0) { messages_per_flush = 100; error_message = "The number of messages per flush must be greater than zero."; is_valid = false; } //--- No errors found return(is_valid); } };
Una vez lista la configuración, por fin podemos empezar a implementar el controlador.
Implementación del controlador de base de datos
Ahora que hemos estructurado nuestra configuración, pasemos a la parte práctica: la implementación del controlador de la base de datos. Detallaré cada parte de la implementación, explicando las decisiones tomadas y asegurándome de que el controlador sea flexible para futuras mejoras.
Comenzamos definiendo la clase CLogifyHandlerDatabase, que extiende CLogifyHandler. Esta clase necesita almacenar la configuración del controlador, una utilidad de control de tiempo (CInvalWatcher) y una caché de mensajes de registro. Esta caché sirve para evitar la escritura excesiva en la base de datos, almacenando temporalmente los mensajes antes de escribirlos.
class CLogifyHandlerDatabase : public CLogifyHandler { private: //--- Config MqlLogifyHandleDatabaseConfig m_config; //--- Update utilities CIntervalWatcher m_interval_watcher; //--- Cache data MqlLogifyModel m_cache[]; int m_index_cache; public: CLogifyHandlerDatabase(void); ~CLogifyHandlerDatabase(void); //--- Configuration management void SetConfig(MqlLogifyHandleDatabaseConfig &config); MqlLogifyHandleDatabaseConfig GetConfig(void); virtual void Emit(MqlLogifyModel &data); // Processes a log message and sends it to the specified destination virtual void Flush(void); // Clears or completes any pending operations virtual void Close(void); // Closes the handler and releases any resources };
El constructor inicializa los atributos, asegurándose de que el nombre del controlador sea "database", establece un intervalo para m_interval_watcher y borra la caché. En el destructor, llamamos a Close(), asegurándonos de que todos los registros pendientes se escriban antes de finalizar el objeto.
Otro método importante es SetConfig(), que permite configurar el controlador, almacenar la configuración y validarla para garantizar que no haya errores. El método GetConfig() simplemente devuelve la configuración actual.
CLogifyHandlerDatabase::CLogifyHandlerDatabase(void) { m_name = "database"; m_interval_watcher.SetInterval(PERIOD_D1); ArrayFree(m_cache); m_index_cache = 0; } CLogifyHandlerDatabase::~CLogifyHandlerDatabase(void) { this.Close(); } void CLogifyHandlerDatabase::SetConfig(MqlLogifyHandleDatabaseConfig &config) { m_config = config; string err_msg = ""; if(!m_config.ValidateConfig(err_msg)) { Print("[ERROR] ["+TimeToString(TimeCurrent())+"] Log system error: "+err_msg); } } MqlLogifyHandleDatabaseConfig CLogifyHandlerDatabase::GetConfig(void) { return(m_config); }
Ahora vamos a llegar al meollo del asunto del gestor de la base de datos: guardar directamente los registros de log. Para ello, implementaremos los tres métodos básicos de cada manejador:
- Emit(MqlLogifyModel &data): Procesa un mensaje de registro y lo envía a la caché.
- Flush(): Finaliza o borra cualquier operación agregando información al destino correcto (archivo, consola, base de datos, etc.).
- Close(): Cierra el controlador y libera los recursos asociados.
Comenzando con el método Emit(), que es responsable de agregar los datos a la caché, y si ha alcanzado el límite definido, llama a Flush().
//+------------------------------------------------------------------+ //| Processes a log message and sends it to the specified destination| //+------------------------------------------------------------------+ void CLogifyHandlerDatabase::Emit(MqlLogifyModel &data) { //--- Checks if the configured level allows if(data.level >= this.GetLevel()) { //--- Resize cache if necessary int size = ArraySize(m_cache); if(size != m_config.messages_per_flush) { ArrayResize(m_cache, m_config.messages_per_flush); size = m_config.messages_per_flush; } //--- Add log to cache m_cache[m_index_cache++] = data; //--- Flush if cache limit is reached or update condition is met if(m_index_cache >= m_config.messages_per_flush || m_interval_watcher.Inspect()) { //--- Save cache Flush(); //--- Reset cache m_index_cache = 0; for(int i=0;i<size;i++) { m_cache[i].Reset(); } } } } //+------------------------------------------------------------------+
Continuando con el método Flush(), leemos los datos de la caché y los añadimos a la base de datos, siguiendo la misma estructura que expliqué al principio del artículo, en la sección «Cómo insertar datos en la base de datos», utilizando la función DatabasePrepare().
//+------------------------------------------------------------------+ //| Clears or completes any pending operations | //+------------------------------------------------------------------+ void CLogifyHandlerDatabase::Flush(void) { //--- Get the full path of the file string path = m_config.directory+"\\"+m_config.base_filename+".sqlite"; //--- Open database ResetLastError(); int handle_db = DatabaseOpen(path,DATABASE_OPEN_CREATE|DATABASE_OPEN_READWRITE); if(handle_db == INVALID_HANDLE) { Print("[ERROR] ["+TimeToString(TimeCurrent())+"] Log system error: Unable to open log file '"+path+"'. Asegúrate de que el directorio existe y de que se puede escribir en él. (Code: "+IntegerToString(GetLastError())+")"); return; } if(!DatabaseTableExists(handle_db,"logs")) { DatabaseExecute(handle_db, "CREATE TABLE logs (" "id INTEGER PRIMARY KEY AUTOINCREMENT," "formated TEXT," "levelname TEXT," "msg TEXT," "args TEXT," "timestamp BIGINT," "date_time DATETIME," "level BIGINT," "origin TEXT," "filename TEXT," "function TEXT," "line BIGINT);"); } //--- string sql="INSERT INTO logs (formated, levelname, msg, args, timestamp, date_time, level, origin, filename, function, line) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11);"; // parâmetro de consulta int request = DatabasePrepare(handle_db,sql); if(request == INVALID_HANDLE) { Print("Erro"); } //--- Loop through all cached messages int size = ArraySize(m_cache); for(int i=0;i<size;i++) { if(m_cache[i].timestamp > 0) { DatabaseBind(request,0,m_cache[i].formated); DatabaseBind(request,1,m_cache[i].levelname); DatabaseBind(request,2,m_cache[i].msg); DatabaseBind(request,3,m_cache[i].args); DatabaseBind(request,4,m_cache[i].timestamp); DatabaseBind(request,5,TimeToString(m_cache[i].date_time,TIME_DATE|TIME_MINUTES|TIME_SECONDS)); DatabaseBind(request,6,(int)m_cache[i].level); DatabaseBind(request,7,m_cache[i].origin); DatabaseBind(request,8,m_cache[i].filename); DatabaseBind(request,9,m_cache[i].function); DatabaseBind(request,10,m_cache[i].line); DatabaseRead(request); DatabaseReset(request); } } //--- DatabaseFinalize(request); //--- Close database DatabaseClose(handle_db); } //+------------------------------------------------------------------+
Por último, Close() garantiza que todos los registros pendientes se escriban antes de salir.
void CLogifyHandlerDatabase::Close(void) { Flush(); }
Con esto, hemos implementado un gestor robusto que garantiza que los registros se almacenen de forma eficiente y sin pérdida de datos. Ahora que ya tenemos nuestro gestor de bases de datos listo para registrar los registros, el siguiente paso es crear métodos eficaces para consultar dichos registros. La idea es disponer de un método base genérico, denominado Query(), que reciba un comando SQL en formato de cadena y devuelva los datos en una matriz de tipo MqlLogifyModel. A partir de ahí, podemos crear métodos específicos para facilitar las consultas recurrentes. Nuestro método Query() se encargará de abrir la base de datos, ejecutar la consulta y almacenar los resultados en la estructura de registro; véase la implementación a continuación:
class CLogifyHandlerDatabase : public CLogifyHandler { public: //--- Query methods bool Query(string query, MqlLogifyModel &data[]); }; //+------------------------------------------------------------------+ //| Get data by sql command | //+------------------------------------------------------------------+ bool CLogifyHandlerDatabase::Query(string query, MqlLogifyModel &data[]) { //--- Get the full path of the file string path = m_config.directory+"\\"+m_config.base_filename+".sqlite"; //--- Open database ResetLastError(); int handle_db = DatabaseOpen(path,DATABASE_OPEN_READWRITE); if(handle_db == INVALID_HANDLE) { Print("[ERROR] ["+TimeToString(TimeCurrent())+"] Log system error: Unable to open log file '"+path+"'. Asegúrate de que el directorio existe y de que se puede escribir en él. (Code: "+IntegerToString(GetLastError())+")"); return(false); } //--- Prepare the SQL query int request = DatabasePrepare(handle_db,query); if(request == INVALID_HANDLE) { Print("Erro query"); return(false); } //--- Clears array before inserting new data ArrayFree(data); //--- Reads query results line by line for(int i=0;DatabaseRead(request);i++) { int size = ArraySize(data); ArrayResize(data,size+1,size); //--- Maps database data to the MqlLogifyModel model DatabaseColumnText(request,1,data[size].formated); DatabaseColumnText(request,2,data[size].levelname); DatabaseColumnText(request,3,data[size].msg); DatabaseColumnText(request,4,data[size].args); DatabaseColumnLong(request,5,data[size].timestamp); string value; DatabaseColumnText(request,6,value); data[size].date_time = StringToTime(value); DatabaseColumnInteger(request,7,data[size].level); DatabaseColumnText(request,8,data[size].origin); DatabaseColumnText(request,9,data[size].filename); DatabaseColumnText(request,10,data[size].function); DatabaseColumnLong(request,11,data[size].line); } //--- Ends the query and closes the database DatabaseFinalize(handle_db); DatabaseClose(handle_db); return(true); } //+------------------------------------------------------------------+
Este método nos ofrece total flexibilidad para realizar cualquier consulta SQL en la base de datos de registros. Sin embargo, para facilitar su uso, crearemos métodos auxiliares que engloben las consultas más habituales.
Para evitar que los desarrolladores tengan que escribir código SQL cada vez que quieran consultar los registros, he creado métodos que ya incluyen los comandos SQL más habituales. Sirven como atajos para buscar en los registros filtrando por nivel de gravedad, fecha, origen, mensaje, argumentos, nombre de archivo y nombre de función. A continuación se muestran los comandos SQL correspondientes a cada uno de estos filtros:
SELECT * FROM 'logs' WHERE level=1;
SELECT * FROM 'logs' WHERE timestamp BETWEEN '{start_time}' AND '{stop_time}';
SELECT * FROM 'logs' WHERE origin LIKE '%{origin}%';
SELECT * FROM 'logs' WHERE msg LIKE '%{msg}%';
SELECT * FROM 'logs' WHERE args LIKE '%{args}%';
SELECT * FROM 'logs' WHERE filename LIKE '%{filename}%';
SELECT * FROM 'logs' WHERE function LIKE '%{function}%'; Ahora implementamos los métodos específicos que utilizan estos comandos:
class CLogifyHandlerDatabase : public CLogifyHandler { public: //--- Query methods bool Query(string query, MqlLogifyModel &data[]); bool QueryByLevel(ENUM_LOG_LEVEL level, MqlLogifyModel &data[]); bool QueryByDate(datetime start_time, datetime stop_time, MqlLogifyModel &data[]); bool QueryByOrigin(string origin, MqlLogifyModel &data[]); bool QueryByMsg(string msg, MqlLogifyModel &data[]); bool QueryByArgs(string args, MqlLogifyModel &data[]); bool QueryByFile(string file, MqlLogifyModel &data[]); bool QueryByFunction(string function, MqlLogifyModel &data[]); }; //+------------------------------------------------------------------+ //| Get logs filtering by level | //+------------------------------------------------------------------+ bool CLogifyHandlerDatabase::QueryByLevel(ENUM_LOG_LEVEL level, MqlLogifyModel &data[]) { return(this.Query("SELECT * FROM 'logs' WHERE level="+IntegerToString(level)+";",data)); } //+------------------------------------------------------------------+ //| Get logs filtering by start end stop time | //+------------------------------------------------------------------+ bool CLogifyHandlerDatabase::QueryByDate(datetime start_time, datetime stop_time, MqlLogifyModel &data[]) { return(this.Query("SELECT * FROM 'logs' WHERE timestamp BETWEEN '"+IntegerToString((ulong)start_time)+"' AND '"+IntegerToString((ulong)stop_time)+"';",data)); } //+------------------------------------------------------------------+ //| Get logs filtering by origin | //+------------------------------------------------------------------+ bool CLogifyHandlerDatabase::QueryByOrigin(string origin, MqlLogifyModel &data[]) { return(this.Query("SELECT * FROM 'logs' WHERE origin LIKE '%"+origin+"%';",data)); } //+------------------------------------------------------------------+ //| Get logs filtering by message | //+------------------------------------------------------------------+ bool CLogifyHandlerDatabase::QueryByMsg(string msg, MqlLogifyModel &data[]) { return(this.Query("SELECT * FROM 'logs' WHERE msg LIKE '%"+msg+"%';",data)); } //+------------------------------------------------------------------+ //| Get logs filtering by args | //+------------------------------------------------------------------+ bool CLogifyHandlerDatabase::QueryByArgs(string args, MqlLogifyModel &data[]) { return(this.Query("SELECT * FROM 'logs' WHERE args LIKE '%"+args+"%';",data)); } //+------------------------------------------------------------------+ //| Get logs filtering by file name | //+------------------------------------------------------------------+ bool CLogifyHandlerDatabase::QueryByFile(string file, MqlLogifyModel &data[]) { return(this.Query("SELECT * FROM 'logs' WHERE filename LIKE '%"+file+"%';",data)); } //+------------------------------------------------------------------+ //| Get logs filtering by function name | //+------------------------------------------------------------------+ bool CLogifyHandlerDatabase::QueryByFunction(string function, MqlLogifyModel &data[]) { return(this.Query("SELECT * FROM 'logs' WHERE function LIKE '%"+function+"%';",data)); } //+------------------------------------------------------------------+
Ahora disponemos de un conjunto de métodos eficaces y flexibles para acceder a los registros directamente desde la base de datos. El método Query() permite ejecutar cualquier comando SQL, incluso comandos más complejos con más filtros según las necesidades específicas, mientras que los métodos auxiliares encapsulan consultas comunes, lo que hace que su uso sea más intuitivo y reduce los errores.
Ahora que ya hemos implementado nuestro controlador, es hora de comprobar si todo funciona correctamente. Visualicemos los resultados y comprobemos que los registros se almacenan y recuperan según lo previsto.
Visualización del resultado
Tras implementar el controlador, el siguiente paso es verificar si funciona como se espera. Necesitamos probar la inserción de registros, validar que los registros se almacenen correctamente en la base de datos y asegurarnos de que las consultas sean rápidas y precisas.
En las pruebas, usaré el mismo archivo LogifyTest.mq5 y simplemente agregaré algunos mensajes de registro al principio. También añadiremos algunas operaciones sin una estrategia compleja, simplemente abriremos una posición si no hay ninguna abierta, definiendo el take profit y el stop loss en la posición para realizar la salida.
//+------------------------------------------------------------------+ //| Import CLogify | //+------------------------------------------------------------------+ #include <Logify/Logify.mqh> #include <Trade/Trade.mqh> CLogify logify; CTrade trade; //+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit() { //--- Configs MqlLogifyHandleDatabaseConfig m_config; m_config.directory = "db"; m_config.base_filename = "logs"; m_config.messages_per_flush = 5; //--- Handler Database CLogifyHandlerDatabase *handler_database = new CLogifyHandlerDatabase(); handler_database.SetConfig(m_config); handler_database.SetLevel(LOG_LEVEL_DEBUG); handler_database.SetFormatter(new CLogifyFormatter("hh:mm:ss","{date_time} [{levelname}] {msg}")); //--- Add handler in base class logify.AddHandler(handler_database); //--- Using logs logify.Info("Expert starting successfully", "Boot", "",__FILE__,__FUNCTION__,__LINE__); return(INIT_SUCCEEDED); } //+------------------------------------------------------------------+ //| Expert deinitialization function | //+------------------------------------------------------------------+ void OnDeinit(const int reason) { //--- } //+------------------------------------------------------------------+ //| Expert tick function | //+------------------------------------------------------------------+ void OnTick() { //--- No positions if(PositionsTotal() == 0) { double price_entry = SymbolInfoDouble(_Symbol,SYMBOL_ASK); double volume = 1; if(trade.Buy(volume,_Symbol,price_entry,price_entry - 100 * _Point, price_entry + 100 * _Point,"Buy at market")) { logify.Debug("Transaction data | Price: "+DoubleToString(price_entry,_Digits)+" | Symbol: "+_Symbol+" | Volume: "+DoubleToString(volume,2), "CTrade", "",__FILE__,__FUNCTION__,__LINE__); logify.Info("Purchase order sent successfully", "CTrade", "",__FILE__,__FUNCTION__,__LINE__); } else { logify.Debug("Error code: "+IntegerToString(trade.ResultRetcode(),_Digits)+" | Description: "+trade.ResultRetcodeDescription(), "CTrade", "",__FILE__,__FUNCTION__,__LINE__); logify.Error("Failed to send purchase order", "CTrade", "",__FILE__,__FUNCTION__,__LINE__); } } } //+------------------------------------------------------------------+
Al probar el probador de estrategias durante 1 día en EURUSD, fue suficiente para generar 909 registros de log. Tal como lo configuramos, se guardaron en el archivo .sqlite. Para acceder a ellos, simplemente acceda a la carpeta de la terminal o pulse “Ctrl/Cmd + Shift + D” y aparecerá el explorador de archivos. Siga la ruta “MQL5/Files/db/logs.sqlite”. Con el archivo en mano, podemos abrirlo directamente en el MetaEditor, como hicimos anteriormente:

Esto supone un nuevo avance en nuestra biblioteca de registros. Ahora, nuestros registros se pueden almacenar y recuperar de forma eficiente en una base de datos, lo que proporciona una mayor escalabilidad y organización.
Conclusión
A lo largo de este artículo, hemos explorado la integración de bases de datos en nuestra biblioteca de registros, desde los conceptos fundamentales hasta la implementación práctica de un controlador específico. En primer lugar, hablamos de la importancia de las bases de datos como una alternativa más escalable y estructurada para almacenar registros, destacando sus ventajas sobre los archivos de texto convencionales. A continuación, examinamos las particularidades del uso de bases de datos en el contexto de MQL5, abordando sus limitaciones y las soluciones disponibles para superarlas.
Finalmente, analizamos los resultados de nuestra implementación, asegurándonos de que los registros se almacenaran correctamente y se pudiera acceder a ellos de forma rápida y eficiente. Además, hablamos sobre cómo visualizar estos registros, ya sea mediante consultas directas a la base de datos o mediante herramientas específicas para la monitorización de registros. Este proceso de validación fue esencial para garantizar que la solución implementada fuera funcional y eficaz en escenarios del mundo real.
Con esto, hemos completado otra etapa en el desarrollo de nuestra biblioteca de registros. La adopción de bases de datos para almacenar registros ha aportado beneficios significativos, haciendo que la gestión de registros sea más organizada, accesible y escalable. Este enfoque nos permite gestionar grandes volúmenes de datos de forma más eficiente, además de facilitar el análisis y la monitorización de la información registrada por el sistema.
Traducción del inglés realizada por MetaQuotes Ltd.
Artículo original: https://www.mql5.com/en/articles/17709
Advertencia: todos los derechos de estos materiales pertenecen a MetaQuotes Ltd. Queda totalmente prohibido el copiado total o parcial.
Este artículo ha sido escrito por un usuario del sitio web y refleja su punto de vista personal. MetaQuotes Ltd. no se responsabiliza de la exactitud de la información ofrecida, ni de las posibles consecuencias del uso de las soluciones, estrategias o recomendaciones descritas.
Utilizando redes neuronales en MetaTrader
Asesor de autoaprendizaje con red neuronal basada en matriz de estados
Particularidades del trabajo con números del tipo double en MQL4
Creación de un Panel de administración de operaciones en MQL5 (Parte IX): Organización del código (IV): Clase sobre el Panel de gestión de operaciones
- Aplicaciones de trading gratuitas
- 8 000+ señales para copiar
- Noticias económicas para analizar los mercados financieros
Usted acepta la política del sitio web y las condiciones de uso