Programación gráfica para principiantes (Parte II): Dominando la Interfaz, POO y Persistencia
En la primera parte de esta serie logramos escapar del letargo del OnTick tradicional. Construimos un bucle de alta frecuencia en CCanvas de 83 fotogramas por segundo, le dimos vida a un cohete con gravedad y aprendimos a detectar colisiones mediante geometría matemática pura.
Sin embargo, siendo honestos, como desarrolladores de MQL5 rara vez buscamos crear minijuegos. Nuestro objetivo es construir herramientas institucionales, EAs avanzados y paneles de trading profesionales, sin fallos. Y para llegar a ese nivel, nuestro código actual tiene tres grandes deficiencias que no podemos pasar por alto:
-
El código espagueti: En el artículo anterior manejamos todo con variables globales. Si quisiéramos añadir 10 botones interactivos a un panel de trading, tendríamos que crear docenas de variables independientes, lo que vuelve el código insostenible y propenso a errores.
-
Interactividad deficiente: Nuestro prototipo solo leía el teclado. Una interfaz profesional exige botones que respondan al puntero del ratón, que se iluminen de forma natural al pasar por encima (hover) y, lo más crítico, que no registren múltiples clics fantasma por error.
-
Amnesia total: Al cambiar de temporalidad o reiniciar MetaTrader 5, nuestro récord (g_bestScore) se esfumaba. Un EA real necesita recordar parámetros vitales (como el Drawdown máximo alcanzado en el día o los lotes configurados) aunque se reinicie el servidor VPS.
En esta segunda y última parte, convertiremos el motor básico de Crazy Scalper en un sistema de interfaz profesional. Aplicaremos Programación Orientada a Objetos (POO), manipularemos colores a nivel de bits (Bitwise), dominaremos los eventos del ratón resolviendo el "efecto rebote", y guardaremos datos en el disco duro mediante archivos binarios (.bin).
Al finalizar, dispondrá de una plantilla gráfica robusta y lista para compilar que servirá como armazón para cualquier herramienta de trading que desee crear.
El caos de las variables y la solución POO
Imagine que desea dibujar un simple botón en CCanvas. Para que exista, necesitará conocer su posición horizontal (x), vertical (y), su ancho (w), su alto (h), el texto que lleva dentro y sus colores para los distintos estados. Si usa variables globales para gestionar apenas 5 botones, terminará con más de 30 variables inconexas flotando en su código.
Para solucionar esto de raíz, encapsularemos todas las propiedades matemáticas y visuales de un botón dentro de una clase. Piense en una clase como el plano arquitectónico de un edificio. Una vez que define el plano, puede construir tantos edificios (botones) como quiera, y cada uno mantendrá sus propios datos internos aislados del resto.
//+------------------------------------------------------------------+ //| Object-Oriented UI Engine (Interactive Buttons) | //+------------------------------------------------------------------+ enum ENUM_BTN_STATE { STATE_NORMAL, STATE_HOVER, STATE_PRESSED }; class CUIButton { public: int m_x, m_y, m_w, m_h; // Geometry: X, Y, Width, Height string m_text; // Text displayed inside the button ENUM_BTN_STATE m_state; // Current interaction state uint m_colNormal; // Background color (Idle) uint m_colHover; // Background color (Mouse Over) uint m_colText; // Text color CUIButton() : m_state(STATE_NORMAL) {} // Constructor //--- Instantiates the button with its geometric and visual properties void Init(int x, int y, int w, int h, string text, uint cNorm, uint cHov, uint cText) { m_x = x; m_y = y; m_w = w; m_h = h; m_text = text; m_colNormal = cNorm; m_colHover = cHov; m_colText = cText; m_state = STATE_NORMAL; } //--- Math: AABB Hitbox detection for the mouse cursor bool HitTest(int mouseX, int mouseY) { return (mouseX >= m_x && mouseX <= m_x + m_w && mouseY >= m_y && mouseY <= m_y + m_h); } //--- Renders the button on the canvas based on its current state void Draw(CCanvas &cv) { //--- 1. Decide background color based on hover state uint bg = (m_state == STATE_HOVER || m_state == STATE_PRESSED) ? m_colHover : m_colNormal; //--- 2. Draw the solid background and the border cv.FillRectangle(m_x, m_y, m_x + m_w, m_y + m_h, bg); cv.Rectangle(m_x, m_y, m_x + m_w, m_y + m_h, g_currentTheme.textMain); //--- 3. Calculate perfectly centered text coordinates cv.FontSet("Arial", 14, FW_BOLD); int txtW = 0, txtH = 0; cv.TextSize(m_text, txtW, txtH); int tX = m_x + (m_w - txtW) / 2; int tY = m_y + (m_h - txtH) / 2; //--- 4. Print the text cv.TextOut(tX, tY, m_text, m_colText, TA_LEFT|TA_TOP); } };
Detengámonos para analizar la matemática detrás de esto, porque aquí es donde ocurre la verdadera ingeniería:
El prefijo m_: Verá que nuestras variables dentro de la clase empiezan por m_ (como m_x). Es una convención profesional que significa Member (Miembro). Nos ayuda a saber inmediatamente que esa variable le pertenece exclusivamente a ese botón y no es una variable global suelta.
La matemática del ratón (HitTest): Los botones nativos de MetaTrader 5 (OBJ_BUTTON) a veces parpadean o no responden bien si se solapan. En CCanvas, nosotros calculamos el clic con precisión de un píxel. Como aprendimos en la Parte I, la coordenada (0,0) está en la esquina superior izquierda de la pantalla. La función HitTest realiza una pregunta matemática simple:
¿La coordenada X del ratón es mayor que el borde izquierdo del botón (m_x) y menor que su borde derecho (m_x + m_w)?
Si esto se cumple para el eje X, y también para el eje Y (altura), significa que el puntero está exactamente dentro del rectángulo. Así de simple es la detección de cajas AABB.
El centrado del texto: En el método Draw(), usamos cv.TextSize() para preguntarle a MetaTrader exactamente cuántos píxeles de ancho y alto ocupa nuestro texto según la fuente seleccionada. Luego, para centrarlo perfectamente dentro del botón, tomamos el ancho total de la caja, le restamos el ancho de la palabra y lo dividimos entre dos. Así logramos un centrado impecable sin importar si la palabra es corta ("PLAY") o larga ("SETTINGS").
Con esta clase definida, crear menús enteros ahora requiere apenas unas pocas líneas de código. Solo debemos declarar nuestros objetos e inicializarlos:
//--- Instances of our interactive UI buttons CUIButton btnPlay; // Main button to start gameplay CUIButton btnThemes; // Button to open Settings/Themes CUIButton btnThemeCyber; // Theme selector: Cyberpunk CUIButton btnThemeInst; // Theme selector: Institutional CUIButton btnThemeRose; // Theme selector: Dreamcore Rose CUIButton btnBack; // Universal return button //+------------------------------------------------------------------+ //| Construct the UI based on actual Screen Dimensions | //+------------------------------------------------------------------+ void InitializeUI() { int cx = g_chartWidth / 2; int cy = g_chartHeight / 2; int bw = 240; // Button Width int bh = 45; // Button Height int gap = 15; // Vertical space between buttons //--- Main Menu btnPlay.Init(cx - bw/2, cy + 20, bw, bh, "PLAY (Buy / Sell)", g_currentTheme.bull, LighterColor(g_currentTheme.bull), g_currentTheme.textMain); btnThemes.Init(cx - bw/2, cy + 20 + bh + gap, bw, bh, "SETTINGS (Themes)", g_currentTheme.btnBg, g_currentTheme.btnHover, g_currentTheme.textMain); //--- Theme Menu int ty = cy - 40; btnThemeCyber.Init(cx - bw/2, ty, bw, bh, "Theme: Cyberpunk", g_currentTheme.btnBg, g_currentTheme.btnHover, g_currentTheme.textMain); btnThemeInst.Init(cx - bw/2, ty + bh + gap, bw, bh, "Theme: Institutional", g_currentTheme.btnBg, g_currentTheme.btnHover, g_currentTheme.textMain); btnThemeRose.Init(cx - bw/2, ty + (bh + gap)*2, bw, bh, "Theme: Dream Rose", g_currentTheme.btnBg, g_currentTheme.btnHover, g_currentTheme.textMain); btnBack.Init(cx - bw/2, ty + (bh + gap)*3 + 20, bw, bh, "BACK TO MENU", g_currentTheme.bear, LighterColor(g_currentTheme.bear), g_currentTheme.textMain); }
Domando al ratón: El problema de los 83 clics por segundo
Ahora que tenemos botones, necesitamos que la interfaz escuche al usuario. Para ello, activamos el seguimiento del puntero en nuestra función de inicialización mediante ChartSetInteger(0, CHART_EVENT_MOUSE_MOVE, true). Esto obliga a la plataforma a informarnos cada vez que el usuario mueve el ratón o pulsa un botón.
Pero aquí nos enfrentamos a un problema físico y matemático clásico: el efecto rebote (Click Spam).
Nuestro motor gráfico se actualiza a unos 83 fotogramas por segundo (cada 12 milisegundos). Un humano promedio, por muy rápido que sea, tarda unos 150 a 200 milisegundos en presionar y soltar el botón del ratón.
¿Qué significa esto? Si escribimos una regla ingenua que diga "Si el botón izquierdo está presionado, ejecuta una compra", nuestro motor leerá esa orden durante 15 fotogramas seguidos antes de que usted logre levantar el dedo. En un EA, esto se traduciría en 15 operaciones abiertas accidentalmente en una fracción de segundo, destruyendo el margen de su cuenta.
Para aislar un clic, debemos detectar el “borde de subida”, como se conoce en electrónica. Es decir, el milisegundo exacto en el que el estado pasa de estar suelto (0) a estar apretado (1).
//+------------------------------------------------------------------+ //| Chart Event Handler (Keyboard & Mouse Inputs) | //+------------------------------------------------------------------+ void OnChartEvent(const int id, const long &lparam, const double &dparam, const string &sparam) { //--- 1. MOUSE INTERCEPTOR (High Frequency UI Routing) static int lastMouseState = 0; // Remembers if mouse was clicked in the previous millisecond if(id == CHARTEVENT_MOUSE_MOVE) { int mx = (int)lparam; // Mouse X position in pixels int my = (int)dparam; // Mouse Y position in pixels int mouseState = (int)StringToInteger(sparam); // 1 = Left Click down, 0 = Button released //--- PHASE A: HOVER DETECTION (Mouse moving without clicking) if(g_gameState == START_SCREEN) { btnPlay.m_state = btnPlay.HitTest(mx, my) ? STATE_HOVER : STATE_NORMAL; btnThemes.m_state = btnThemes.HitTest(mx, my) ? STATE_HOVER : STATE_NORMAL; } else if(g_gameState == THEME_SCREEN) { btnThemeCyber.m_state = btnThemeCyber.HitTest(mx, my) ? STATE_HOVER : STATE_NORMAL; btnThemeInst.m_state = btnThemeInst.HitTest(mx, my) ? STATE_HOVER : STATE_NORMAL; btnThemeRose.m_state = btnThemeRose.HitTest(mx, my) ? STATE_HOVER : STATE_NORMAL; btnBack.m_state = btnBack.HitTest(mx, my) ? STATE_HOVER : STATE_NORMAL; } //--- PHASE B: CLICK DETECTION (State transition from 0 to 1) if(mouseState == 1 && lastMouseState == 0) { if(g_gameState == START_SCREEN) { if(btnPlay.HitTest(mx, my)) { ResetGame(); g_gameState = PLAYING; } if(btnThemes.HitTest(mx, my)) { g_gameState = THEME_SCREEN; } } else if(g_gameState == THEME_SCREEN) { bool themeChanged = false; if(btnThemeCyber.HitTest(mx, my)) { ApplyTheme(0); themeChanged = true; } if(btnThemeInst.HitTest(mx, my)) { ApplyTheme(1); themeChanged = true; } if(btnThemeRose.HitTest(mx, my)) { ApplyTheme(2); themeChanged = true; } //--- If user clicked a theme, we recalculate colors and save to disk if(themeChanged) { InitializeUI(); SaveGameData(); } //--- Return to Main Menu if(btnBack.HitTest(mx, my)) { g_gameState = START_SCREEN; } } } lastMouseState = mouseState; // Save memory for the next tick } //--- 2. KEYBOARD INTERCEPTOR (Classic Gameplay) if(id == CHARTEVENT_KEYDOWN) { //--- Check if the pressed key is SPACE (32) or UP ARROW (38) if(lparam == 32 || lparam == 38) { if(g_gameState == START_SCREEN) { ResetGame(); g_gameState = PLAYING; } else if(g_gameState == PLAYING) { g_velocityY = JUMP_STRENGTH; // Apply thrust } else if(g_gameState == GAME_OVER) { g_gameState = START_SCREEN; } } } }
Observe la elegancia de esta solución con la variable estática lastMouseState. Al ser estática, su valor no se borra cuando la función termina; actúa como la memoria celular de nuestro ratón.
-
Fase A (Hover): Comprobamos constantemente si las coordenadas del ratón coinciden con la caja de los botones mediante HitTest. Si es así, cambiamos su estado a STATE_HOVER. La clase se encargará de pintarlo más claro en el próximo fotograma.
-
Fase B (Clic limpio): La condición de oro es if(mouseState == 1 && lastMouseState == 0). Esto se traduce como: Si en este milisegundo estás apretando el botón, pero en el fotograma anterior NO lo estabas apretando, entonces es un clic nuevo. Ejecutamos la acción, y actualizamos nuestra "memoria celular" para el siguiente ciclo. Adiós a las operaciones duplicadas.
Psicología del color y manipulación a nivel de bits (Bitwise)
En el diseño de interfaces de trading, el color no es solo decoración, es ergonomía. Pasar horas frente a monitores con rojos (#FF0000) y verdes (#00FF00) puros genera estrés y fatiga visual severa. Las herramientas institucionales utilizan paletas matemáticamente equilibradas: azules noche, grises desaturados y tonos pastel.
Para aplicar esto de forma limpia, creamos una estructura STheme que almacena nuestra paleta activa. Pero aquí surge un reto técnico: para lograr el efecto hover en los botones, necesitamos una versión "más clara" del color base. Podríamos definir ambos colores a mano, pero la forma profesional es enseñarle al motor a calcular la luz dinámicamente.
En MQL5, cuando trabajamos con transparencias (canal Alpha), los colores se guardan en variables uint (enteros sin signo de 32 bits). Estos 32 bits se dividen en 4 bloques de 8 bits cada uno: Alpha, Red, Green y Blue (ARGB). Cada bloque acepta un valor del 0 al 255.
//+------------------------------------------------------------------+ //| Math Helper: Increase brightness of an ARGB color dynamically | //+------------------------------------------------------------------+ uint LighterColor(uint argb_color, int offset = 30) { //--- Extract individual channels using Bitwise Shifts int a = (int)((argb_color >> 24) & 0xFF); int r = (int)((argb_color >> 16) & 0xFF); int g = (int)((argb_color >> 8) & 0xFF); int b = (int)(argb_color & 0xFF); //--- Add the offset making sure we don't exceed pure white (255) r = (int)MathMin(255, r + offset); g = (int)MathMin(255, g + offset); b = (int)MathMin(255, b + offset); //--- Reassemble the 32-bit integer return (uint)((a << 24) | (r << 16) | (g << 8) | b); } //+------------------------------------------------------------------+ //| Math Helper: Removes Alpha channel to convert ARGB to MT5 color | //+------------------------------------------------------------------+ color ConvertARGBtoColor(uint argb_color) { //--- 0x00FFFFFF acts as a mask, erasing the first 8 bits (Alpha) return (color)(argb_color & 0x00FFFFFF); }
¿Cómo funciona este bisturí matemático (LighterColor)?
-
El operador de desplazamiento de bits >> empuja los números hacia la derecha. Si queremos el color rojo (Red), empujamos el paquete 16 posiciones.
-
La máscara & 0xFF actúa como un filtro. Borra todo el ruido alrededor y aísla únicamente los 8 bits que nos interesan, entregándonos un valor decimal limpio del 0 al 255.
-
Con los colores separados (R, G, B), les sumamos nuestro incremento de luz (offset). Usamos MathMin(255, ...) para evitar que un número supere el límite. (Si un canal supera 255 en un sistema de 8 bits, el valor da la vuelta y vuelve a cero, corrompiendo el color).
-
Finalmente, el operador << vuelve a colocar cada color en su asiento, y el operador | (OR) los fusiona nuevamente en un solo paquete de 32 bits.
Con esta función, si decidimos cambiar el color primario de nuestra herramienta de azul a naranja oscuro, el motor calculará automáticamente la sombra perfecta para el ratón sin que tengamos que programar nada más.
El disco duro virtual: Archivos binarios (.bin)
Llegamos al problema final: la amnesia del terminal. Cuando un usuario rompe el récord del juego, o elige el tema "Institutional Blue", necesitamos que esa información sobreviva incluso si MetaTrader 5 se cierra totalmente.
Un error común en desarrolladores novatos es guardar estos datos en archivos de texto (.txt o .csv). Leer y escribir texto es un proceso ineficiente para el procesador. Requiere abrir el archivo, leer una línea de texto, lidiar con separadores (comas), y usar funciones pesadas como StringToDouble() para convertir ese texto en variables útiles. Además, los archivos de texto son vulnerables a la configuración regional de Windows (en algunos países los decimales usan punto, en otros usan coma, lo que rompe los EAs constantemente).
La solución definitiva son los archivos binarios (.bin). En un archivo binario no guardamos texto, guardamos información pura.
Primero, creamos una "cápsula" de datos agrupando nuestra información en una estructura plana (POD - Plain Old Data):
//+------------------------------------------------------------------+ //| Data Persistence (Virtual Hard Drive) | //+------------------------------------------------------------------+ struct SGameSave { int bestScore; // Memorizes the All-Time High score int themeIndex; // Memorizes the user's preferred visual theme };
Y luego, usamos las funciones de volcado directo de memoria de MQL5:
//+------------------------------------------------------------------+ //| Load data from Virtual Hard Drive (.bin format) | //+------------------------------------------------------------------+ void LoadGameData() { //--- If it's the user's first time, the file won't exist. We just return. if(!FileIsExist("CrazyScalper_Save.bin")) return; //--- Open file with BINARY and READ permissions int file = FileOpen("CrazyScalper_Save.bin", FILE_READ|FILE_BIN); if(file != INVALID_HANDLE) { SGameSave data; //--- Extract the struct directly from RAM if(FileReadStruct(file, data) > 0) { g_bestScore = data.bestScore; g_themeIndex = data.themeIndex; } FileClose(file); } } //+------------------------------------------------------------------+ //| Save data to Virtual Hard Drive (.bin format) | //+------------------------------------------------------------------+ void SaveGameData() { //--- Open file with BINARY and WRITE permissions (overwrites previous) int file = FileOpen("CrazyScalper_Save.bin", FILE_WRITE|FILE_BIN); if(file != INVALID_HANDLE) { SGameSave data; data.bestScore = g_bestScore; data.themeIndex = g_themeIndex; //--- Dump the struct directly to disk FileWriteStruct(file, data); FileClose(file); } }
El funcionamiento es brillante por su simpleza. La función FileWriteStruct toma nuestra estructura en la memoria RAM y, sin importar cuántas variables tenga adentro, toma una "fotografía" exacta de esos bits y la escribe cruda en el disco duro. Cero formateo, cero conversiones de texto, cero errores de comas.
Cuando el EA despierta en el OnInit(), la función inversa FileReadStruct toma ese bloque de bits del disco duro y lo inyecta de golpe en la memoria RAM. En milisegundos, nuestro sistema recuerda el récord exacto y el tema visual preferido del usuario, dibujando la pantalla correctamente en el primer fotograma.
Conclusión: Listo para el mercado
Si junta las piezas de este artículo con las de la Parte I, habrá construido algo mucho más profundo que un juego arcade. Ha creado desde cero los cimientos de un Motor Gráfico (UI Engine) autónomo.
Ahora dispone de una plantilla profesional donde:
- La pantalla se renderiza a alta frecuencia sin quedar congelada esperando un tick del mercado.
- Los botones se comportan con solidez gracias a la Programación Orientada a Objetos.
- Los clics del ratón se capturan mediante cajas de colisión (AABB), aislando el rebote físico.
- Los colores se adaptan mediante interpolación matemática de bits.
- Los parámetros críticos son inmortales gracias al volcado binario en el disco local.
Descargue el archivo fuente adjunto (Crazy_Scalper_v2.mq5), compílelo y analícelo. El siguiente paso lógico está en sus manos: sustituya la lógica del juego por un gráfico de beneficio/pérdida flotante, reemplace el cohete por botones de "Buy" y "Sell", y conecte este motor visual a la librería CTrade. Acaba de adquirir los conocimientos para programar ese Panel de Operaciones Institucional que siempre quiso tener.
¡Feliz codificación y buen trading!
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
Introducción a MQL5 (Parte 20): Introducción a los patrones armónicos
Particularidades del trabajo con números del tipo double en MQL4
De novato a experto: Noticias animadas utilizando MQL5 (IX) Gestión de múltiples símbolos en un único gráfico para el trading de noticias
- 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