Creación de un sistema de trading profesional con Heikin Ashi (Parte 2): Desarrollo de un EA
Introducción
Este artículo es la segunda parte de la serie «Creación de un sistema de trading profesional con Heikin Ashi». En la primera parte, creamos un indicador Heikin Ashi personalizado utilizando MetaQuotes Language 5 (MQL5), siguiendo las mejores prácticas para el desarrollo de indicadores personalizados. En esta parte, damos un paso más y desarrollamos un Asesor Experto llamado «Zen Breakout», que utiliza nuestro indicador Heikin Ashi personalizado y el indicador Fractals estándar de MetaTrader 5 para generar señales de ruptura fiables.
El concepto es simple:
- Cuando una vela Heikin Ashi claramente alcista cierra por encima de un máximo de oscilación reciente (detectado por el indicador Fractals), el EA abre una posición larga.
- Cuando una vela Heikin Ashi claramente bajista cierra por debajo de un mínimo de oscilación reciente, el EA abre una posición corta.
Cada operación abierta por nuestro EA tiene niveles de stop-loss y take-profit claramente definidos, con una relación riesgo/beneficio configurable. Después de leer este artículo, usted entenderá cómo:
- Conectar un indicador personalizado y un indicador integrado a un EA.
- Implementar la lógica de entrada en rupturas usando Heikin Ashi y fractales.
- Aplicar un dimensionamiento de posiciones flexible (manual o basado en el porcentaje de riesgo de la cuenta).
- Empaquetar un EA junto con su indicador en un único archivo para facilitar su distribución.
El concepto de la estrategia
La estrategia «Zen Breakout» combina la detección del impulso con la confirmación de la ruptura.
- Configuración de compra
La configuración de compra se da cuando una vela Heikin Ashi alcista fuerte cierra por encima del máximo de oscilación más reciente.

- Configuración de venta
Se produce cuando una vela Heikin Ashi fuerte cierra por debajo del mínimo de oscilación más reciente.

- Colocación del stop-loss
En el caso de las posiciones largas, el stop-loss se sitúa en el mínimo de la vela de ruptura.

En el caso de las posiciones cortas, el stop-loss se sitúa en el máximo de la vela de ruptura.

- Colocación del take-profit
El take-profit se define usando una relación riesgo/beneficio configurable. Por ejemplo, si el riesgo por operación es de 100 puntos y la relación riesgo/beneficio es de 1:2, el take-profit estará a 200 puntos de la entrada.
Preparación del EA
Para generar señales de trading, Zen Breakout leerá los datos directamente de dos indicadores:
- El indicador Fractals
El indicador Fractals viene preinstalado en el terminal MetaTrader 5 y se usa ampliamente para identificar los máximos y mínimos de oscilación recientes del mercado. En nuestro EA, utilizaremos la función iFractals() de MQL5 para inicializar el indicador y obtener su identificador, que luego utilizaremos en nuestro código.
- El indicador Heikin Ashi personalizado
En nuestro caso, utilizaremos el indicador Heikin Ashi personalizado que creamos en la primera parte para detectar rupturas con un fuerte impulso. Para acceder a él mediante programación, utilizaremos la función iCustom() de MQL5 para inicializarlo y obtener su identificador. Además, incluiremos el indicador como recurso dentro del EA para que pueda distribuirse como un único archivo autónomo.
Luego añadiremos los siguientes parámetros de entrada configurables a nuestro EA Zen Breakout para que resulte más flexible.
- magicNumber
El «número mágico» es un identificador único que el EA asigna a cada operación que abre. Esto permite al EA distinguir sus operaciones de las abiertas manualmente o por otros EA, lo que garantiza que solo modifique o cierre sus propias posiciones.
- timeFrame
Este parámetro especifica el marco temporal del gráfico en el que debe operar el EA. Los usuarios pueden elegir entre los 21 marcos temporales disponibles en MetaTrader 5, que van desde M1 (1 minuto) hasta MN1 (mensual).
- lotSizeMode
Determina cómo calcula el EA el tamaño del lote para las nuevas posiciones:
- Manual: el usuario especifica un tamaño de lote fijo en el parámetro «lotSize».
- Auto: el EA calcula el tamaño del lote de forma dinámica en función del saldo de la cuenta y del parámetro «riskPerTradePercent».
- riskPerTradePercent
Indica el porcentaje del saldo de la cuenta que se arriesgará por operación (se utiliza únicamente cuando el parámetro «lotSizeMode» está configurado en modo Auto). Por ejemplo, si el saldo de la cuenta es de 10 000 $ y este parámetro se establece en 1,0, el EA ajustará el tamaño de las posiciones de tal forma que, si se activa el stop-loss, la pérdida sea de 100 $ (el 1 % de 10 000 $).
- lotSize
Especifica un tamaño de lote fijo para todas las operaciones nuevas (se usa únicamente cuando el parámetro «lotSizeMode» está configurado en modo Manual). Por ejemplo, cuando el parámetro «lotSize» se establece en 0,5, cada nueva posición se abrirá con un volumen de 0,5 lotes.
- RRr (relación riesgo-beneficio)
Define la relación riesgo/beneficio de cada operación. Los usuarios pueden elegir entre las siete relaciones predefinidas para que los beneficios potenciales superen a las pérdidas potenciales cuando se alcance el nivel de take-profit.
Guía paso a paso para programar el Asesor Experto
Este artículo da por hecho que usted ya está familiarizado con los conceptos básicos de programación y que tiene una sólida experiencia en el uso del lenguaje MQL5 en MetaTrader 5 y MetaEditor. No trataremos esos temas, así que empezaremos a programar nuestro EA directamente. Prepare un archivo fuente en blanco en MetaEditor. Ya estamos listos para comenzar a programar. Empezaremos con el código estándar inicial. Utilizaremos esto como base para desarrollar el EA.
//+------------------------------------------------------------------+ //| zenBreakout.mq5 | //| Copyright 2025, MetaQuotes Ltd. Developer is Chacha Ian | //| https://www.mql5.com/en/users/chachaian | //+------------------------------------------------------------------+ #property copyright "Copyright 2025, MetaQuotes Ltd. Developer is Chacha Ian" #property link "https://www.mql5.com/en/users/chachaian" #property version "1.10" //+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit(){ return(INIT_SUCCEEDED); } //+------------------------------------------------------------------+ //| Expert deinitialization function | //+------------------------------------------------------------------+ void OnDeinit(const int reason){ } //+------------------------------------------------------------------+ //| Expert tick function | //+------------------------------------------------------------------+ void OnTick(){ } //--- Utility functions //+------------------------------------------------------------------+
El siguiente paso consiste en añadir las funciones personalizadas para nuestro EA. Añada las siguientes funciones justo debajo de la función OnTick(). Llamaremos a estas funciones una por una desde nuestro código fuente a medida que desarrollemos nuestro EA.
... //+------------------------------------------------------------------+ //| Expert tick function | //+------------------------------------------------------------------+ void OnTick(){ } //--- Utility functions //+------------------------------------------------------------------+ //| This function configures the chart's appearance. | | //+------------------------------------------------------------------+ bool ConfigureChartAppearance() { if(!ChartSetInteger(0, CHART_COLOR_BACKGROUND, clrWhite)){ Print("Error while setting chart background, ", GetLastError()); return false; } if(!ChartSetInteger(0, CHART_SHOW_GRID, false)){ Print("Error while setting chart grid, ", GetLastError()); return false; } if(!ChartSetInteger(0, CHART_MODE, CHART_LINE)){ Print("Error while setting chart mode, ", GetLastError()); return false; } if(!ChartSetInteger(0, CHART_COLOR_FOREGROUND, clrBlack)){ Print("Error while setting chart foreground, ", GetLastError()); return false; } return true; } //+-------------------------------------------------------------------------+ //| Function to generate a unique graphical object name with a given prefix | | //+-------------------------------------------------------------------------+ string GenerateUniqueName(string prefix){ int attempt = 0; string uniqueName; while(true) { uniqueName = prefix + IntegerToString(MathRand() + attempt); if(ObjectFind(0, uniqueName) < 0) break; attempt++; } return uniqueName; } //+-------------------------------------------------------------------------+ //| Returns true if Heikin Ashi candle is bullish and has no lower wick | | //+-------------------------------------------------------------------------+ bool IsBullishBreakoutCandle(int index) { if(index < 0 || index >= ArraySize(heikinAshiOpen)) return false; double open = heikinAshiOpen[index]; double close = heikinAshiClose[index]; double low = heikinAshiLow[index]; //--- Candle must be bullish and have no lower wick return (close > open && low >= MathMin(open, close)); } //+-------------------------------------------------------------------------+ //| Returns true if Heikin Ashi candle is bearish and has no upper wick | | //+-------------------------------------------------------------------------+ bool IsBearishBreakoutCandle(int index) { if(index < 0 || index >= ArraySize(heikinAshiOpen)) return false; double open = heikinAshiOpen[index]; double close = heikinAshiClose[index]; double high = heikinAshiHigh[index]; //--- Candle must be bearish and have no upper wick return (close < open && high <= MathMax(open, close)); } //+----------------------------------------------------------------------------------------------+ //| Returns the index of the most recent swing high before 'fromIndex'. Returns -1 if not found | | //+----------------------------------------------------------------------------------------------+ int FindMostRecentSwingHighIndex(int fromIndex) { if(fromIndex <= 0 || fromIndex >= ArraySize(swingHighs)) fromIndex = 1; for(int i = fromIndex; i < ArraySize(swingHighs); i++) { if(swingHighs[i] != EMPTY_VALUE) return i; } return -1; //--- No swing high found } //+----------------------------------------------------------------------------------------------+ //| Returns the index of the most recent swing low before 'fromIndex'. Returns -1 if not found | | //+----------------------------------------------------------------------------------------------+ int FindMostRecentSwingLowIndex(int fromIndex) { if(fromIndex <= 0 || fromIndex >= ArraySize(swingLows)) fromIndex = 1; for(int i = fromIndex; i < ArraySize(swingLows); i++) { if(swingLows[i] != EMPTY_VALUE) return i; } return -1; // No swing low found } //+------------------------------------------------------------------+ //| This function detects a bullish signal | //+------------------------------------------------------------------+ bool IsBullishSignal(datetime &timeStart, int &indexStart, datetime &timeEnd, int &indexEnd) { indexStart = FindMostRecentSwingHighIndex(1); double recentSwingHigh = iHigh(_Symbol, timeframe, indexStart); double previousHeikinAshiCandleClose = heikinAshiClose[1]; double previousHeikinAshiCandleOpen = heikinAshiOpen[1]; if(IsBullishBreakoutCandle(1)){ if(previousHeikinAshiCandleClose > recentSwingHigh && previousHeikinAshiCandleOpen < recentSwingHigh){ timeStart = iTime(_Symbol, timeframe, indexStart); indexEnd = 0; timeEnd = iTime(_Symbol, timeframe, indexEnd); return true; } } return false; } //+------------------------------------------------------------------+ //| This function detects a bearish signal | //+------------------------------------------------------------------+ bool IsBearishSignal(datetime &timeStart, int &indexStart, datetime &timeEnd, int &indexEnd) { indexStart = FindMostRecentSwingLowIndex(1); double recentSwingLow = iLow(_Symbol, timeframe, indexStart); double previousHeikinAshiCandleClose = heikinAshiClose[1]; double previousHeikinAshiCandleOpen = heikinAshiOpen[1]; if(IsBearishBreakoutCandle(1)){ if(previousHeikinAshiCandleClose < recentSwingLow && previousHeikinAshiCandleOpen > recentSwingLow){ timeStart = iTime(_Symbol, timeframe, indexStart); indexEnd = 0; timeEnd = iTime(_Symbol, timeframe, indexEnd); return true; } } return false; } //+-------------------------------------------------------------------+ //| Function to check if there's a new bar on a given chart timeframe | | //+-------------------------------------------------------------------+ bool IsNewBar(string symbol, ENUM_TIMEFRAMES tf, datetime &lastTm) { datetime currentTime = iTime(symbol, tf, 0); if(currentTime != lastTm){ lastTm = currentTime; return true; } return false; } //+------------------------------------------------------------------+ //| To check if there is an active buy position opened by this EA | | //+------------------------------------------------------------------+ bool IsThereAnActiveBuyPosition(ulong magicNm){ for(int i = PositionsTotal() - 1; i >= 0; i--){ ulong ticket = PositionGetTicket(i); if(ticket == 0){ Print("Error while fetching position ticket ", _LastError); continue; }else{ if(PositionGetInteger(POSITION_MAGIC) == magicNm && PositionGetInteger(POSITION_TYPE) == POSITION_TYPE_BUY){ return true; } } } return false; } //+------------------------------------------------------------------+ //| To check if there is an active sell position opened by this EA | | //+------------------------------------------------------------------+ bool IsThereAnActiveSellPosition(ulong mgcNumber){ for(int i = PositionsTotal() - 1; i >= 0; i--){ ulong ticket = PositionGetTicket(i); if(ticket == 0){ Print("Error while fetching position ticket ", _LastError); continue; }else{ if(PositionGetInteger(POSITION_MAGIC) == mgcNumber && PositionGetInteger(POSITION_TYPE) == POSITION_TYPE_SELL){ return true; } } } return false; } //+------------------------------------------------------------------+ //| To open a buy position | //+------------------------------------------------------------------+ bool OpenBuy(){ double rewardValue = 1.0; switch(RRr){ case ONE_TO_ONE: rewardValue = 1.0; break; case ONE_TO_ONEandHALF: rewardValue = 1.5; break; case ONE_TO_TWO: rewardValue = 2.0; break; case ONE_TO_THREE: rewardValue = 3.0; break; case ONE_TO_FOUR: rewardValue = 4.0; break; case ONE_TO_FIVE: rewardValue = 5.0; break; case ONE_TO_SIX: rewardValue = 6.0; break; default: rewardValue = 1.0; break; } ENUM_POSITION_TYPE positionType = POSITION_TYPE_BUY; ENUM_ORDER_TYPE action = ORDER_TYPE_BUY; double stopLevel = iLow(_Symbol, timeframe, 1); double askPrice = AppData.askPrice; double bidPrice = AppData.bidPrice; double stopDistance = askPrice - stopLevel; double targetLevel = askPrice + (stopDistance * rewardValue); double lotSz = AppData.amountAtRisk / (AppData.contractSize * stopDistance); if(lotSizeMode == MODE_AUTO){ lotSz = NormalizeDouble(lotSz, 2); }else{ lotSz = NormalizeDouble(lotSize, 2); } if(!Trade.Buy(lotSz, _Symbol, askPrice, stopLevel, targetLevel)){ Print("Error while opening a long position, ", GetLastError()); Print(Trade.ResultRetcode()); Print(Trade.ResultComment()); return false; }else{ MqlTradeResult result = {}; Trade.Result(result); AppData.tradeInfo.orderTicket = result.order; AppData.tradeInfo.type = action; AppData.tradeInfo.posType = positionType; AppData.tradeInfo.entryPrice = result.price; AppData.tradeInfo.takeProfitLevel = targetLevel; AppData.tradeInfo.stopLossLevel = stopLevel; AppData.tradeInfo.openTime = AppData.currentGmtTime; AppData.tradeInfo.lotSize = lotSz; return true; } return false; } //+------------------------------------------------------------------+ //| To open a sell position | //+------------------------------------------------------------------+ bool OpenSel(){ double rewardValue = 1.0; switch(RRr){ case ONE_TO_ONE: rewardValue = 1.0; break; case ONE_TO_ONEandHALF: rewardValue = 1.5; break; case ONE_TO_TWO: rewardValue = 2.0; break; case ONE_TO_THREE: rewardValue = 3.0; break; case ONE_TO_FOUR: rewardValue = 4.0; break; case ONE_TO_FIVE: rewardValue = 5.0; break; case ONE_TO_SIX: rewardValue = 6.0; break; default: rewardValue = 1.0; break; } ENUM_POSITION_TYPE positionType = POSITION_TYPE_SELL; ENUM_ORDER_TYPE action = ORDER_TYPE_SELL; double stopLevel = iHigh(_Symbol, timeframe, 1); double bidPrice = AppData.bidPrice; double askPrice = AppData.askPrice; double stopDistance = stopLevel - bidPrice; double targetLevel = bidPrice - (stopDistance * rewardValue); double lotSz = AppData.amountAtRisk / (AppData.contractSize * stopDistance); if(lotSizeMode == MODE_AUTO){ lotSz = NormalizeDouble(lotSz, 2); }else{ lotSz = NormalizeDouble(lotSize, 2); } if(!Trade.Sell(lotSz, _Symbol, bidPrice, stopLevel, targetLevel)){ Print("Error while opening a short position, ", GetLastError()); Print(Trade.ResultRetcode()); Print(Trade.ResultComment()); return false; }else{ MqlTradeResult result = {}; Trade.Result(result); AppData.tradeInfo.orderTicket = result.order; AppData.tradeInfo.type = action; AppData.tradeInfo.posType = positionType; AppData.tradeInfo.entryPrice = result.price; AppData.tradeInfo.takeProfitLevel = targetLevel; AppData.tradeInfo.stopLossLevel = stopLevel; AppData.tradeInfo.openTime = AppData.currentGmtTime; AppData.tradeInfo.lotSize = lotSz; return true; } return false; } //+------------------------------------------------------------------+
Si intenta compilar el EA ahora, verá varios errores de compilación. Esto se debe a que muchas de las funciones que ha añadido hacen referencia a variables que aún no se han definido. Más adelante añadiremos estas variables al ámbito global. Por ahora, vamos a repasar la funcionalidad de cada función y a explicar qué hace.
- ConfigureChartAppearance
Esta función configura el aspecto visual del gráfico antes de ejecutar el EA. Así, garantiza una visualización limpia y minimalista al cambiar el fondo del gráfico a blanco, ocultar la cuadrícula para lograr un aspecto despejado, cambiar el modo del gráfico a un gráfico de líneas y establecer el color de primer plano en negro para obtener un buen contraste.
- GenerateUniqueName
Se utiliza para generar un nombre único para cada objeto gráfico recién creado en el gráfico en el que se ejecuta nuestro EA. Esto garantiza que cada objeto dibujado por el EA tenga un identificador único, lo que evita que se sobrescriban accidentalmente los objetos dibujados anteriormente. La función recibe una cadena como entrada, le aplica un algoritmo y genera un identificador único para el objeto.
- IsBullishBreakoutCandle
Esta función comprueba si una vela Heikin Ashi, especificada por su índice, cumple los criterios para una ruptura alcista, verificando las siguientes condiciones específicas.
- El precio de cierre de la vela debe ser superior a su precio de apertura.
- La vela no debe tener mecha inferior.
Si se cumplen ambas condiciones, la función devuelve «true», lo cual indica que la vela Heikin Ashi cumple los requisitos para considerarse una vela de ruptura alcista.
- IsBearishBreakoutCandle
Esta función comprueba si una vela Heikin Ashi, especificada por su índice, cumple los criterios para una ruptura bajista.
- FindMostRecentSwingHighIndex
El objetivo principal de esta función es hallar el índice del máximo de oscilación más reciente. Para ello, analiza un array de valores de fractales superiores, que se obtienen directamente del búfer número cero del indicador Fractals. La función busca específicamente el máximo de oscilación más reciente que se haya producido antes de un índice determinado, que se introduce como dato de entrada.
- FindMostRecentSwingLowIndex
El objetivo de esta función es hallar el índice del mínimo de oscilación más reciente.
- IsBullishSignal
Esta función comprueba si se ha formado una señal válida de ruptura alcista. En primer lugar, localiza el máximo de oscilación más reciente y recupera su precio. A continuación, obtiene los valores de apertura y cierre de la vela Heikin Ashi anterior. Si la última vela es alcista, no tiene mecha inferior y su cierre se sitúa por encima del máximo de oscilación, mientras que su apertura se sitúa por debajo de ese nivel, la función registra las horas de inicio y fin como referencia y devuelve «true». De lo contrario, devuelve «false».
- IsBearishSignal
Esta función comprueba si se ha formado una señal válida de ruptura bajista.
- IsNewBar
Esta función comprueba si se ha formado una nueva barra en el símbolo y el marco temporal especificados. Compara la hora de apertura de la barra actual con la hora anteriormente guardada. Si difieren, actualiza la hora almacenada y devuelve «true»; en caso contrario, devuelve «false».
- IsThereAnActiveBuyPosition
Esta función comprueba si hay alguna posición de compra activa abierta específicamente por este EA. Admite como entrada un número mágico, que es un identificador único asignado a las operaciones del EA. La función recorre todas las posiciones abiertas y, si encuentra una posición de compra cuyo número mágico coincida con el proporcionado, devuelve «true»; en caso contrario, devuelve «false».
- IsThereAnActiveSellPosition
Esta función comprueba si hay alguna posición de venta activa abierta específicamente por este EA.
- OpenBuy
Esta función se encarga de abrir una posición de compra según los parámetros de relación riesgo/beneficio y las normas de gestión del riesgo del EA. Comienza seleccionando el multiplicador de beneficio en función de la relación riesgo/beneficio definida por el usuario (1:1, 1:1,5, 1:2, etc.). Luego calcula el nivel de stop-loss en el mínimo de la vela anterior y mide la distancia del stop respecto al precio Ask actual. A partir de esta distancia de stop, calcula el nivel de take-profit multiplicando dicha distancia por la relación de beneficio elegida, lo que garantiza que la operación respete el perfil de relación riesgo/beneficio especificado.
A continuación, la función determina el tamaño del lote. Si el modo de tamaño del lote está configurado en automático, calcula el tamaño del lote en función del importe en riesgo, el tamaño del contrato y la distancia del stop, y luego lo normaliza a dos decimales. Si se selecciona el modo manual, utiliza en su lugar el tamaño del lote definido por el usuario.
Por último, la función intenta enviar una orden de compra utilizando los parámetros calculados. Si la operación se coloca correctamente, almacena información detallada sobre la orden (número de ticket, tipo, precio de entrada, stop-loss, take-profit, tamaño del lote y hora) en una variable estructurada, AppData.tradeInfo, para consultarla posteriormente. Si la orden falla, imprime mensajes de error detallados para facilitar la depuración y devuelve false.
Esta función integra de forma eficaz la gestión del riesgo, el cálculo del beneficio potencial y la ejecución de operaciones de trading del EA en un único proceso bien estructurado, lo que la convierte en uno de los pilares fundamentales del EA Zen Breakout.
- OpenSel
Esta función se comporta exactamente igual que la función «OpenBuy», con la única diferencia de que abre una posición corta en lugar de una larga.
Ahora que ya tenemos configurada la estructura del Asesor Experto (EA), el siguiente paso es definir sus parámetros de entrada. Puede hacerlo declarándolos justo debajo de las directivas #property, en la parte superior del archivo de su programa. Solo tiene que añadir el siguiente fragmento de código en esa ubicación:
... #property copyright "Copyright 2025, MetaQuotes Ltd. Developer is Chacha Ian" #property link "https://www.mql5.com/en/users/chachaian" #property version "1.10" //--- Input parameters input group "Information" input ulong magicNumber = 254700680002; input ENUM_TIMEFRAMES timeframe = PERIOD_CURRENT; input group "Risk Management" input ENUM_LOT_SIZE_INPUT_MODE lotSizeMode = MODE_AUTO; input double riskPerTradePercent = 1.0; input double lotSize = 0.1; input ENUM_RISK_REWARD_RATIO RRr = ONE_TO_ONEandHALF; //+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit(){ return(INIT_SUCCEEDED); } ...
Como ya hemos definido y explicado estos parámetros en la sección «Preparación del EA», no repetiremos aquí sus descripciones. A continuación, crearemos enumeraciones personalizadas para algunos de nuestros parámetros de entrada. Las enumeraciones ofrecen a los usuarios una lista desplegable de opciones predefinidas cuando configuran el EA. Definiremos nuestras enumeraciones personalizadas justo debajo de las directivas #property y por encima de los parámetros de entrada.
... #property copyright "Copyright 2025, MetaQuotes Ltd. Developer is Chacha Ian" #property link "https://www.mql5.com/en/users/chachaian" #property version "1.10" //--- Custom enumerations enum ENUM_RISK_REWARD_RATIO { ONE_TO_ONE, ONE_TO_ONEandHALF, ONE_TO_TWO, ONE_TO_THREE, ONE_TO_FOUR, ONE_TO_FIVE, ONE_TO_SIX }; enum ENUM_LOT_SIZE_INPUT_MODE { MODE_MANUAL, MODE_AUTO }; //--- Input parameters input group "Information" input ulong magicNumber = 254700680002; input ENUM_TIMEFRAMES timeframe = PERIOD_CURRENT; ...
Nuestro código incluye dos enumeraciones personalizadas que hacen que la configuración del EA resulte más intuitiva:
- ENUM_RISK_REWARD_RATIO
Esta enumeración define siete opciones preestablecidas de relación riesgo/beneficio (de 1:1 a 1:6). Esto permite a los traders seleccionar fácilmente la relación que prefieran en una lista desplegable, en lugar de introducir los valores manualmente.
- ENUM_LOT_SIZE_INPUT_MODE
Determina cómo calcula el EA el tamaño del lote. MODE_MANUAL permite al usuario establecer un tamaño de lote fijo, mientras que MODE_AUTO calcula el tamaño del lote de forma dinámica según el saldo de la cuenta y el porcentaje de riesgo.
A continuación, definimos una macro llamada «zenBreakout», que almacena el nombre del EA como una cadena de caracteres. Esta macro se utiliza posteriormente en nuestra función personalizada GenerateUniqueName() para crear nombres únicos para los nuevos objetos gráficos. Ahora colocaremos la definición de la macro justo debajo de las directivas #property existentes.
... #property copyright "Copyright 2025, MetaQuotes Ltd. Developer is Chacha Ian" #property link "https://www.mql5.com/en/users/chachaian" #property version "1.10" //--- Macros #define zenBreakout "zenBreakout" //--- Custom enumerations enum ENUM_RISK_REWARD_RATIO { ONE_TO_ONE, ONE_TO_ONEandHALF, ONE_TO_TWO, ONE_TO_THREE, ONE_TO_FOUR, ONE_TO_FIVE, ONE_TO_SIX }; enum ENUM_LOT_SIZE_INPUT_MODE { MODE_MANUAL, MODE_AUTO }; ...
Luego incluimos las bibliotecas necesarias justo debajo de la definición de nuestra enumeración personalizada.
... //--- Custom enumerations enum ENUM_RISK_REWARD_RATIO { ONE_TO_ONE, ONE_TO_ONEandHALF, ONE_TO_TWO, ONE_TO_THREE, ONE_TO_FOUR, ONE_TO_FIVE, ONE_TO_SIX }; enum ENUM_LOT_SIZE_INPUT_MODE { MODE_MANUAL, MODE_AUTO }; //--- Libraries #include <Trade\Trade.mqh> #include <ChartObjects\ChartObjectsLines.mqh> //--- Input parameters input group "Information" input ulong magicNumber = 254700680002; input ENUM_TIMEFRAMES timeframe = PERIOD_CURRENT; ...
- Trade.mqh
- ChartObjectsLines.mqh
Nos permite acceder a clases para crear y gestionar objetos de línea en el gráfico, que utilizaremos más adelante para mostrar rupturas confirmadas por encima y por debajo de los puntos de oscilación recientes.
Después definimos dos estructuras de datos justo debajo de nuestros parámetros de entrada para organizar y almacenar la información clave que utiliza el EA.
... //--- Input parameters input group "Information" input ulong magicNumber = 254700680002; input ENUM_TIMEFRAMES timeframe = PERIOD_CURRENT; input group "Risk Management" input ENUM_LOT_SIZE_INPUT_MODE lotSizeMode = MODE_AUTO; input double riskPerTradePercent = 1.0; input double lotSize = 0.1; input ENUM_RISK_REWARD_RATIO RRr = ONE_TO_ONEandHALF; //--- Data Structures struct MqlTradeInfo { ulong orderTicket; ENUM_ORDER_TYPE type; ENUM_POSITION_TYPE posType; double entryPrice; double takeProfitLevel; double stopLossLevel; datetime openTime; double lotSize; }; struct MqlAppData { double bidPrice; double askPrice; double currentBalance; double currentEquity; datetime currentGmtTime; datetime lastDailyCheckTime; datetime lastBarOpenTime; double contractSize; long digitValue; double amountAtRisk; MqlTradeInfo tradeInfo; }; //+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit(){ return(INIT_SUCCEEDED); } ...
- MqlTradeInfo
Esta estructura de datos contiene toda la información sobre la posición activa, incluido el número de ticket, el tipo de orden, el tipo de posición, el precio de entrada, el stop-loss, el take-profit, el tamaño del lote y la hora de apertura.
- MqlAppData
Esta estructura de datos sirve como contenedor de datos a nivel de aplicación, tales como los precios Bid y Ask, el balance de la cuenta, la equity de la cuenta, la hora GMT, la hora de apertura de la última barra, el tamaño del contrato, los dígitos del símbolo y el importe en riesgo por operación. Además, contiene una instancia de MqlTradeInfo, lo que nos permite mantener agrupada en un mismo lugar tanto la información de la cuenta como la de la operación.
A continuación, vamos a declarar variables globales, a las que se podrá acceder desde todo el EA. Las declararemos justo debajo de nuestras estructuras de datos.
... //--- Data Structures struct MqlTradeInfo { ulong orderTicket; ENUM_ORDER_TYPE type; ENUM_POSITION_TYPE posType; double entryPrice; double takeProfitLevel; double stopLossLevel; datetime openTime; double lotSize; }; struct MqlAppData { double bidPrice; double askPrice; double currentBalance; double currentEquity; datetime currentGmtTime; datetime lastDailyCheckTime; datetime lastBarOpenTime; double contractSize; long digitValue; double amountAtRisk; MqlTradeInfo tradeInfo; }; //--- Global variables CTrade Trade; CChartObjectTrend TrendLine; MqlAppData AppData; int heikinAshiIndicatorHandle; double heikinAshiOpen []; double heikinAshiHigh []; double heikinAshiLow []; double heikinAshiClose []; int fractalsIndicatorHandle; double swingHighs []; double swingLows []; //+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit(){ return(INIT_SUCCEEDED); } ...
- CTrade Trade
Una instancia de la clase CTrade que se utiliza para gestionar operaciones de trading, como la apertura, el cierre y la modificación de órdenes y posiciones.
- CChartObjectTrend Trendline
Representa un objeto de línea de tendencia que podemos trazar y manipular en el gráfico
- MqlAppData AppData
Una instancia de nuestra estructura MqlAppData que nos permite almacenar todos los datos a nivel de aplicación y acceder a ellos desde cualquier parte del código.
También declaramos los identificadores de los indicadores y algunos arrays para almacenar los valores leídos de nuestro indicador Heikin Ashi personalizado y del indicador Fractals integrado. Estos almacenarán los valores de los indicadores en tiempo real para que el EA pueda analizar la acción del precio y detectar rupturas válidas.
Dentro de nuestra función OnTick(), lo primero que hacemos es actualizar nuestras variables globales para que reflejen siempre los datos más recientes del mercado y de la cuenta en cada tick. Luego añadimos el siguiente bloque de código justo al principio de la función OnTick().
... //+------------------------------------------------------------------+ //| Expert tick function | //+------------------------------------------------------------------+ void OnTick(){ //--- Scope variables AppData.bidPrice = SymbolInfoDouble (_Symbol, SYMBOL_BID); AppData.askPrice = SymbolInfoDouble (_Symbol, SYMBOL_ASK); AppData.currentBalance = AccountInfoDouble(ACCOUNT_BALANCE); AppData.currentEquity = AccountInfoDouble(ACCOUNT_EQUITY); AppData.amountAtRisk = (riskPerTradePercent/100.0) * AppData.currentBalance; } ...
- AppData.bidPrice
Recupera y guarda el precio Bid actual del instrumento financiero concreto en cuyo gráfico se está ejecutando.
- AppData.askPrice
Recupera y guarda el precio Ask actual del instrumento financiero concreto en cuyo gráfico se está ejecutando.
- AppData.currentBalance
Recupera y registra el saldo actual de la cuenta con cada cambio de precio.
- AppData.currentEquity
Recupera y almacena la equity de la cuenta en tiempo real.
- AppData.amountAtRisk
Calcula el importe efectivo que se va a arriesgar en la próxima operación en función del parámetro riskPerTradePercent y del saldo actual de la cuenta.
Una vez que tenemos listas nuestras estructuras de datos y variables globales, el siguiente paso consiste en introducir los indicadores que sustentan nuestra estrategia. Empezaremos inicializando los identificadores de los indicadores, tanto para el indicador Heikin Ashi personalizado que creamos en la primera parte como para el indicador Fractals estándar disponible en MetaTrader 5. Una vez inicializados los identificadores, leeremos los datos en tiempo real de sus búferes para que el EA pueda utilizarlos para detectar señales de ruptura válidas. Para garantizar un uso eficiente de la memoria, también liberaremos los identificadores de los indicadores cuando ya no sean necesarios. Además, empaquetaremos nuestro indicador Heikin Ashi personalizado como recurso dentro del archivo del EA, lo que permitirá distribuirlo como un único archivo autónomo sin que los usuarios tengan que instalar manualmente el indicador por separado.
Antes de poder empaquetar nuestro indicador Heikin Ashi personalizado como recurso, primero debemos crearlo nosotros mismos. Cree un nuevo archivo fuente de indicador vacío en MetaEditor y asígnele el nombre «heikinAshiindicator.mq5». A continuación, copie y pegue el código fuente del indicador adjunto en este archivo y compílelo. Una vez que compile correctamente, MetaTrader generará un archivo llamado «heikinAshiindicator.ex5». A continuación, empaquetaremos este archivo compilado como un recurso para que pase a formar parte de nuestro EA.
... #property copyright "Copyright 2025, MetaQuotes Ltd. Developer is Chacha Ian" #property link "https://www.mql5.com/en/users/chachaian" #property version "1.10" #resource "\\Indicators\\heikinAshiIndicator.ex5" ...
Esto indica al compilador que incorpore el archivo de indicador compilado (heikinAshiIndicator.ex5) en el EA. De este modo, los usuarios no tendrán que instalar el indicador manualmente en su directorio Indicators. El EA siempre podrá acceder a él, siempre que el archivo esté presente en el momento de la compilación. Esto facilita mucho la distribución y garantiza una instalación sin complicaciones para los usuarios finales.
A continuación, inicialicemos los identificadores de los indicadores dentro de la función OnTick() para que nuestro Asesor Experto pueda acceder a los datos en tiempo real tanto del indicador personalizado Heikin Ashi como del indicador Fractals integrado.
... //+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit(){ ... //--- Initialize global variables AppData.currentBalance = AccountInfoDouble(ACCOUNT_BALANCE); AppData.currentEquity = AccountInfoDouble(ACCOUNT_EQUITY); AppData.lastDailyCheckTime = iTime(_Symbol, PERIOD_D1, 0); AppData.lastBarOpenTime = 0; AppData.digitValue = SymbolInfoInteger(_Symbol, SYMBOL_DIGITS); AppData.contractSize = SymbolInfoDouble (_Symbol, SYMBOL_TRADE_CONTRACT_SIZE); //--- Initialize the Heikin Ashi indicator heikinAshiIndicatorHandle = iCustom(_Symbol, timeframe, "::Indicators\\heikinAshiIndicator.ex5"); if(heikinAshiIndicatorHandle == INVALID_HANDLE){ Print("Error while initializing The Heikin Ashi Indicator: ", GetLastError()); return INIT_FAILED; } //--- Initialize the Fractals indicator fractalsIndicatorHandle = iFractals(_Symbol, timeframe); if(fractalsIndicatorHandle == INVALID_HANDLE){ Print("Error while initializing The Fractals Indicator: ", GetLastError()); return INIT_FAILED; } } ...
Acabamos de añadir el código necesario para inicializar los dos indicadores que vamos a utilizar en nuestro EA.
- Indicador Heikin Ashi
Para cargar nuestro indicador Heikin Ashi empaquetado, usamos iCustom(). Si no se crea correctamente el identificador, el EA muestra un mensaje de error en el diario del experto y deja de ejecutarse. Esto resulta útil para la depuración y, además, garantiza que el EA no se ejecute sin su fuente de señal principal.
- Indicador Fractals
Una vez inicializados los indicadores, el siguiente paso dentro de la función OnTick() consiste en leer los datos de sus búferes de indicador. Ahora debemos añadir el siguiente bloque de código dentro de la función OnTick(), colocándolo justo debajo de las instrucciones de asignación de las variables globales.
... //+------------------------------------------------------------------+ //| Expert tick function | //+------------------------------------------------------------------+ void OnTick(){ //--- Scope variables AppData.bidPrice = SymbolInfoDouble (_Symbol, SYMBOL_BID); AppData.askPrice = SymbolInfoDouble (_Symbol, SYMBOL_ASK); AppData.currentBalance = AccountInfoDouble(ACCOUNT_BALANCE); AppData.currentEquity = AccountInfoDouble(ACCOUNT_EQUITY); AppData.amountAtRisk = (riskPerTradePercent/100.0) * AppData.currentBalance; //--- Get a few Heikin Ashi values int copiedHeikinAshiOpen = CopyBuffer(heikinAshiIndicatorHandle, 0, 0, 10, heikinAshiOpen); if(copiedHeikinAshiOpen == -1){ Print("Error while copying Heikin Ashi Open prices: ", GetLastError()); return; } int copiedHeikinAshiHigh = CopyBuffer(heikinAshiIndicatorHandle, 1, 0, 10, heikinAshiHigh); if(copiedHeikinAshiHigh == -1){ Print("Error while copying Heikin Ashi High prices: ", GetLastError()); return; } int copiedHeikinAshiLow = CopyBuffer(heikinAshiIndicatorHandle, 2, 0, 10, heikinAshiLow); if(copiedHeikinAshiLow == -1){ Print("Error while copying Heikin Ashi Low prices: ", GetLastError()); return; } int copiedHeikinAshiClose = CopyBuffer(heikinAshiIndicatorHandle, 3, 0, 10, heikinAshiClose); if(copiedHeikinAshiClose == -1){ Print("Error while copying Heikin Ashi Close prices: ", GetLastError()); return; } //--- Get the latest Fractals indicator values int copiedSwingHighs = CopyBuffer(fractalsIndicatorHandle, 0, 0, 200, swingHighs); if(copiedSwingHighs == -1){ Print("Error while copying fractal's indicator swing highs: ", GetLastError()); } int copiedSwingLows = CopyBuffer(fractalsIndicatorHandle, 1, 0, 200, swingLows); if(copiedSwingLows == -1){ Print("Error while copying fractal's indicator swing lows: ", GetLastError()); } } ...
Así, usamos CopyBuffer() para obtener los 10 últimos valores de los precios de apertura, máximo, mínimo y cierre del indicador Heikin Ashi. Del mismo modo, copiamos los últimos 200 valores de los máximos de oscilación y mínimos de oscilación del indicador Fractals. En cada caso, si la copia falla, registramos un error, pero no interrumpimos la ejecución del programa.
El siguiente paso consiste en establecer nuestras matrices de datos como series mediante la función ArraySetAsSeries(). Esta función invierte el sentido de indexación de nuestras matrices, de modo que el elemento 0 representa el punto de datos de la barra más reciente y los índices más altos representan las barras más antiguas. Después añadimos el siguiente bloque de código justo debajo de la sección en la que inicializamos los identificadores de nuestros indicadores.
... //+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit(){ ... //--- Initialize the Heikin Ashi indicator heikinAshiIndicatorHandle = iCustom(_Symbol, timeframe, "::Indicators\\heikinAshiIndicator.ex5"); if(heikinAshiIndicatorHandle == INVALID_HANDLE){ Print("Error while initializing The Heikin Ashi Indicator: ", GetLastError()); return INIT_FAILED; } //--- Initialize the Fractals indicator fractalsIndicatorHandle = iFractals(_Symbol, timeframe); if(fractalsIndicatorHandle == INVALID_HANDLE){ Print("Error while initializing The Fractals Indicator: ", GetLastError()); return INIT_FAILED; } //--- Set Arrays as series ArraySetAsSeries(heikinAshiOpen, true); ArraySetAsSeries(heikinAshiHigh, true); ArraySetAsSeries(heikinAshiLow, true); ArraySetAsSeries(heikinAshiClose, true); ArraySetAsSeries(swingHighs, true); ArraySetAsSeries(swingLows, true); return(INIT_SUCCEEDED); } ...
Hacer esto resulta de gran ayuda, ya que permite al EA acceder a los valores más recientes de forma fácil y coherente.
El último paso al trabajar con indicadores es asegurarse de que sus recursos se liberen correctamente una vez que el Asesor Experto (EA) se haya desvinculado del gráfico. Esto se hace dentro de la función OnDeinit(), que se ejecuta automáticamente cuando el EA se desvincula del gráfico. Al llamar a la función IndicatorRelease() sobre los identificadores de nuestros indicadores, liberamos toda la memoria que ocupan.
... //+------------------------------------------------------------------+ //| Expert deinitialization function | //+------------------------------------------------------------------+ void OnDeinit(const int reason){ //--- Free up memory used by indicators if(heikinAshiIndicatorHandle != INVALID_HANDLE){ IndicatorRelease(heikinAshiIndicatorHandle); } if(fractalsIndicatorHandle != INVALID_HANDLE){ IndicatorRelease(fractalsIndicatorHandle); } } ...
Siempre es recomendable incluir este paso de limpieza en cada EA que utilice indicadores personalizados o integrados.
Antes de liberar los identificadores de los indicadores, es recomendable eliminar de los gráficos cualquier objeto gráfico que nuestro EA haya podido crear. De este modo se garantiza que no queden líneas de tendencia residuales en el gráfico una vez que el EA se haya desvinculado de él. Lo conseguiremos llamando a ObjectsDeleteAll(0) dentro de la función OnDeinit(). Vamos a hacerlo ahora, justo antes de la sección dedicada a la liberación de memoria.
... //+------------------------------------------------------------------+ //| Expert deinitialization function | //+------------------------------------------------------------------+ void OnDeinit(const int reason){ //--- Delete all graphical objects ObjectsDeleteAll(0); //--- Free up memory used by indicators if(heikinAshiIndicatorHandle != INVALID_HANDLE){ IndicatorRelease(heikinAshiIndicatorHandle); } ... } ...
En esta fase, la compilación del código fuente ya no debería dar lugar a ningún error, ya que todas las variables globales necesarias se han definido e inicializado correctamente.
Por fin hemos llegado al corazón de nuestro Asesor Experto (EA): la lógica de trading principal. Este bloque de código se ejecuta únicamente cuando se abre una nueva barra, lo que garantiza que las señales solo se evalúen una vez, al cierre de la vela. Vamos a insertar nuestra lógica de trading principal justo debajo del código existente, dentro de la función OnTick().
... //+------------------------------------------------------------------+ //| Expert tick function | //+------------------------------------------------------------------+ void OnTick(){ ... //--- Get the latest Fractals indicator values int copiedSwingHighs = CopyBuffer(fractalsIndicatorHandle, 0, 0, 200, swingHighs); if(copiedSwingHighs == -1){ Print("Error while copying fractal's indicator swing highs: ", GetLastError()); } int copiedSwingLows = CopyBuffer(fractalsIndicatorHandle, 1, 0, 200, swingLows); if(copiedSwingLows == -1){ Print("Error while copying fractal's indicator swing lows: ", GetLastError()); } //--- Run this block on new bar open if(IsNewBar(_Symbol, timeframe, AppData.lastBarOpenTime)){ datetime timeStart = 0; int indexStart = 0; datetime timeEnd = 0; int indexEnd = 0; //--- Handle Bullish Signals if(IsBullishSignal(timeStart, indexStart, timeEnd, indexEnd)){ if(!IsThereAnActiveBuyPosition(magicNumber) && !IsThereAnActiveSellPosition(magicNumber)){ OpenBuy(); } double high = iHigh(_Symbol, timeframe, indexStart); TrendLine.Create(0, GenerateUniqueName(zenBreakout), 0, timeStart, high, timeEnd, high); } //--- Handle Bearish Signals if(IsBearishSignal(timeStart, indexStart, timeEnd, indexEnd)){ if(!IsThereAnActiveBuyPosition(magicNumber) && !IsThereAnActiveSellPosition(magicNumber)){ OpenSel(); } double low = iLow (_Symbol, timeframe, indexStart); TrendLine.Create(0, GenerateUniqueName(zenBreakout), 0, timeStart, low, timeEnd, low); } } } ...
En esta sección, lo primero que hacemos es declarar algunas variables auxiliares (timeStart, indexStart, timeEnd, indexEnd) que se rellenarán cuando se encuentre una señal válida.
A continuación, llamamos a nuestra función personalizada IsBullishSignal(). Si se detecta una configuración alcista y no hay posiciones activas de compra o venta, el EA llama a la función OpenBuy() para abrir una nueva posición de compra. Inmediatamente después, traza una línea de tendencia a lo largo del rango de barras identificado mediante TrendLine.Create(), marcando visualmente el punto en el que se produce la señal.
La misma lógica se aplica a las señales bajistas, pero en este caso, el EA llama a OpenSel() para abrir una orden de venta y traza una línea de tendencia en el mínimo de oscilación donde se detectó la configuración bajista.
Todo este bloque es fundamental, ya que vincula los parámetros de configuración y de entrada anteriores, los indicadores y las variables globales para generar finalmente decisiones de trading ejecutables.
¡Enhorabuena por haber llegado hasta aquí! En este punto, nuestro Asesor Experto (EA) está completamente desarrollado y debería compilarse sin errores. Le animo a que descargue el archivo fuente adjunto y lo compare con su propia implementación. Esto le ayudará a detectar cualquier paso que se le haya pasado por alto o cualquier errata, y a asegurarse de que su código coincide con lo que hemos creado juntos. Dedicar tiempo a revisar y comparar su trabajo es una forma estupenda de afianzar sus conocimientos y ganar confianza antes de pasar a probar el EA en un gráfico.
Pruebas
He realizado un backtest utilizando el oro como instrumento financiero, abarcando el periodo comprendido entre el 1 de enero de 2025 y el 31 de agosto de 2025. Los parámetros de entrada se han configurado de la siguiente manera:
- magicNumber: 254700680002
- timeFrame: H1
- lotSizeMode: MODE_AUTO
- riskPerTradePercent: 1.0
- lotSize: 0.1
- RRr: ONE_TO_ONEandHALF
Partiendo de un saldo de cuenta de 100 000 dólares, el backtest muestra un crecimiento de la equity ligeramente superior al 12 % a lo largo de un periodo de ocho meses.

A continuación se muestra la curva de crecimiento de la equity:

La curva de equity indica que la estrategia actual se sitúa aproximadamente en el punto de equilibrio, lo cual es una señal positiva, ya que demuestra que el enfoque no es totalmente deficitario. Creo que existe potencial para mejorar aún más su rendimiento mediante la optimización de los parámetros y la incorporación de filtros de señal avanzados, como las sesiones de trading.
Conclusión
Hemos finalizado con éxito el desarrollo de nuestro Asesor Experto (EA). Juntos, hemos repasado cada paso, desde la configuración de los parámetros de entrada, las enumeraciones y las variables globales, hasta la inicialización de los indicadores, la lectura de datos de sus búferes y la implementación de la lógica central de trading.
Ahora disponemos de un Asesor Experto (EA) totalmente operativo que opera automáticamente utilizando nuestra lógica Heikin Ashi. El backtest con oro ha mostrado una curva de equity relativamente estable, lo que confirma que la lógica funciona según lo previsto y que el EA se compila sin errores. Se trata de un hito muy importante.
A partir de aquí, puede optimizar los parámetros para ver si logra mejorar la rentabilidad, o añadir filtros adicionales, como el horario de las sesiones de trading o comprobaciones de volatilidad. El trabajo que hemos realizado sienta unas bases sólidas para desarrollar sistemas automatizados más sofisticados, capaces de hacer frente a las condiciones reales del mercado.
Traducción del inglés realizada por MetaQuotes Ltd.
Artículo original: https://www.mql5.com/en/articles/18810
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.
Desarrollo de un kit de herramientas para el análisis de la acción del precio (Parte 41): Creación de un EA estadístico de niveles de precio en MQL5
Creación de sistemas de trading basados en IA en MQL5 (Parte 1): Implementación del manejo de JSON para APIs de IA
Particularidades del trabajo con números del tipo double en MQL4
Simplificación de bases de datos en MQL5 (Parte 1): Introducción a las bases de datos y SQL
- 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