Asesores de un solo símbolo frente a la validación del Market: distancias en ATR y una puerta de órdenes portable
Un asesor pensado para XAUUSD suele caer en la validación automática del Market por razones ajenas a su estrategia: stops que se encogen en EURUSD, pruebas sin operaciones en símbolos ajenos, mensajes que no están en inglés y órdenes enviadas con el mercado cerrado. Aquí construyo una clase que calcula el stop antes que el volumen, un asesor de ejemplo con parámetros en unidades de ATR y dos scripts para repetir en local la matriz de pruebas. El código está adjunto al final.
Contenido
- Introducción
- Qué prueba realmente la validación automática
- Tres maneras de fallar con un asesor de un solo símbolo
- Teoría: el stop primero, el volumen después
- Implementación de CSymbolProfile
- Implementación de CTradeGate
- El asesor de ejemplo
- Herramientas: mensajes no ASCII y matriz de pruebas local
- Casos límite
- Notas de prueba
- Conclusión
- Archivos adjuntos
Introducción
Mi asesor TrendScalpTP_XAUUSD estaba hecho para el oro y en el probador de estrategias funcionaba sin un solo error en XAUUSD. Lo subí al Market y la validación automática lo devolvió con 22 errores de tipo Invalid stops. Ninguno era del oro: todos salían de la prueba en EURUSD H1. Corregí eso y en el segundo intento aparecieron 21 errores distintos, esta vez porque los mensajes del programa estaban en español.
Ninguno de los dos problemas tenía que ver con la estrategia. Eran supuestos escondidos en el código: que el símbolo siempre sería XAUUSD, que un dólar de pérdida siempre equivale a la misma distancia en precio y que nadie fuera de mi terminal iba a leer los mensajes. La validación ejecuta el asesor en símbolos y marcos temporales que el autor no eligió, y ahí esos supuestos se rompen.
El artículo de MetaQuotes Las comprobaciones que debe superar un robot comercial antes de ser publicado en el Mercado explica cada verificación por separado, y la serie MQL5 Bootstrap de Omega J Msigwa ya ofrece funciones de validación reutilizables. Aquí el enfoque es otro: cómo diseñar un asesor que nació para un solo símbolo para que se comporte bien en cualquiera, sin bloquearlo y sin trucos que detecten el probador.
Al terminar tendrás:
- CSymbolProfile, una clase con la aritmética dependiente del símbolo: redondeo de precios y volúmenes, distancia mínima del stop y pérdida por lote en la moneda de la cuenta.
- CTradeGate, una puerta única para abrir y modificar posiciones que calcula el stop antes que el volumen y deja una línea de resumen legible por máquina.
- PortableBreakoutEA, un asesor de ruptura de canal con todos sus parámetros expresados en múltiplos del ATR.
- Dos scripts de Python: uno que encuentra caracteres no ASCII en mensajes y nombres de parámetros, y otro que genera los archivos de configuración del probador para repetir la matriz de la validación en tu propio terminal.
Qué prueba realmente la validación automática
MetaQuotes no publica la lista exacta de pruebas y la ha cambiado con el tiempo. En los informes de 2026 que se ven en el foro, y en los míos, un asesor pasa por cuatro ejecuciones en cuenta de cobertura (hedging):
| Símbolo | Marco temporal | Lo que suele romper |
|---|---|---|
| EURUSD | H1 | Stops calculados con valores pensados para el oro |
| XAUUSD | D1 | Historia corta para indicadores y riesgo del lote mínimo |
| GBPUSD | M30 | Filtros en unidades absolutas que nunca se cumplen |
| EURUSD | M1 | ATR muy pequeño frente al spread y al nivel de stops |
Cada prueba puede fallar por dos motivos. El primero son los errores en el registro: órdenes rechazadas por stops, volumen, dinero o precio, divisiones por cero y accesos fuera de rango. El segundo es el mensaje there are no trading operations, que aparece cuando el asesor no hizo ninguna operación en esa prueba. El informe no explica cuál de las condiciones de entrada falló, solo dice que no hubo operaciones.
Esa segunda causa es la que más confunde a quien vende un asesor de un solo símbolo. En el foro hay hilos recientes de autores que cumplieron todas las comprobaciones del artículo oficial y aun así reciben ese mensaje en las cuatro pruebas. En casi todos los casos el asesor se negaba a operar fuera de XAUUSD o tenía filtros que en otros símbolos nunca dejaban pasar una señal.
Tres maneras de fallar con un asesor de un solo símbolo
1. Bloquear el símbolo
Un patrón muy común es comprobar el nombre del símbolo en OnInit o en OnTick y salir sin hacer nada si no es el esperado. En un gráfico real es razonable. En la validación garantiza el mensaje de que no hubo operaciones en EURUSD y GBPUSD, porque el asesor nunca llega a enviar una orden.
La alternativa que uso es dejar que el asesor funcione en cualquier símbolo y avisar en el registro de que los valores por defecto se ajustaron en otro. Si los parámetros están en unidades relativas, el comportamiento en EURUSD será coherente con el del oro, aunque la rentabilidad no tenga por qué serlo. La validación revisa que el programa funcione sin errores; la rentabilidad en un símbolo ajeno es asunto del vendedor y de su descripción.
2. Parámetros en unidades absolutas
Un stop de 300 puntos en XAUUSD con dos decimales son 3 dólares de precio. En EURUSD con cinco decimales, 300 puntos son 30 pips. Un filtro de spread máximo de 50 puntos funciona en el oro y deja pasar todo en EURUSD. Un filtro de volatilidad del tipo ATR mayor que 2.0 se cumple en el oro y jamás en EURUSD, donde el ATR de H1 ronda unas milésimas.
Mi caso fue una variante de este problema. El asesor tenía un tope de pérdida en dólares por operación (el parámetro se llamaba InpMaxSL_USD) y calculaba la distancia del stop a partir de ese tope y del lote. En EURUSD el valor por punto del lote resultante era mucho mayor que en el oro, así que la distancia del stop quedaba en unos 10 puntos, por debajo del nivel mínimo del servidor. De ahí salieron los 22 Invalid stops.
La solución general es expresar todo lo que depende de la escala del precio en múltiplos de una medida de volatilidad del propio símbolo. En este artículo uso el ATR: stops, objetivos, disparador del punto de equilibrio y filtro de spread se definen como fracciones o múltiplos del ATR.
3. Mensajes que no están en inglés
La validación exige que los mensajes del programa estén en inglés. El detalle que me costó el segundo rechazo es que no solo cuentan las cadenas de Print, Alert o Comment. El comentario al final de cada línea input es el nombre que el usuario ve en la ventana de parámetros, y también se revisa. Un input con el comentario // Riesgo por operación (%) basta para el error. Los comentarios normales del código no afectan porque el compilador los descarta.
Teoría: el stop primero, el volumen después
La figura 1 resume el cambio de orden que aplica CTradeGate.

Figura 1. Lote primero frente a stop primero
El servidor mide el nivel de stops desde el precio al que se cerraría la posición, no desde el precio de apertura. Para una compra ese precio es el Bid y para una venta el Ask. Por eso, la clase ancla el stop en ese precio de cierre. Si se ancla en el precio de apertura, el spread se come parte de la distancia y un stop que parecía válido queda dentro de la zona prohibida.
La distancia mínima aceptable se calcula como:
d_min = (1 + b) · max(STOPS_LEVEL · point, FREEZE_LEVEL · point, m · spread, tick_size)
donde b es un margen de seguridad (0.10 por defecto) y m un multiplicador del spread (2.0 por defecto). El término del spread existe porque muchos brókers publican un SYMBOL_TRADE_STOPS_LEVEL igual a cero, y eso no significa que no haya límite: el límite es flotante. Incluyo el nivel de congelación porque un stop colocado dentro de esa zona después ya no se puede modificar.
Con la distancia de la estrategia d_s = k · ATR, la distancia final es max(d_s, d_min). El piso del bróker solo puede ensanchar el stop, nunca encogerlo. Luego, el precio se redondea al tamaño de tick alejándose del mercado, para que el redondeo no devuelva el nivel a la zona prohibida.
Solo con el stop definitivo se calcula el volumen:
volumen = floor((equity · r / 100) / pérdida_por_lote, step)
La pérdida por lote se obtiene con OrderCalcProfit entre el precio de entrada y el stop. Esa función ya convierte entre la moneda de cotización, la de margen y la de la cuenta. Gracias a eso, el mismo código vale para EURUSD con cuenta en USD y para XAUUSD con cuenta en EUR. El redondeo es hacia abajo para que el riesgo real nunca supere el pedido.
Si el margen libre no alcanza, se reduce el volumen. Si el volumen calculado queda por debajo del mínimo del símbolo, se acepta el lote mínimo solo cuando su riesgo no supera un tope duro configurable. Cuando lo supera, la orden se rechaza y se cuenta el motivo. En ningún caso se toca el stop para que entre el lote.
Implementación de CSymbolProfile
La clase lee la especificación una vez en Init y comprueba los valores de los que dependen las divisiones posteriores. Un tamaño de tick igual a cero se reemplaza por el punto, y un paso de volumen nulo hace fallar la inicialización en lugar de provocar una división por cero más adelante.
bool CSymbolProfile::Init(const string symbol) { m_symbol=symbol; if(!SymbolSelect(m_symbol,true)) { PrintFormat("SymbolProfile: cannot select %s, error %d",m_symbol,GetLastError()); return(false); } m_point =SymbolInfoDouble(m_symbol,SYMBOL_POINT); m_tick_size=SymbolInfoDouble(m_symbol,SYMBOL_TRADE_TICK_SIZE); m_digits =(int)SymbolInfoInteger(m_symbol,SYMBOL_DIGITS); m_vol_min =SymbolInfoDouble(m_symbol,SYMBOL_VOLUME_MIN); m_vol_max =SymbolInfoDouble(m_symbol,SYMBOL_VOLUME_MAX); m_vol_step =SymbolInfoDouble(m_symbol,SYMBOL_VOLUME_STEP); m_vol_limit=SymbolInfoDouble(m_symbol,SYMBOL_VOLUME_LIMIT); //--- some symbols publish a zero tick size; the point is the safe fallback if(m_tick_size<=0.0) m_tick_size=m_point; //--- every later division depends on these two values if(m_tick_size<=0.0 || m_vol_step<=0.0 || m_vol_min<=0.0) { PrintFormat("SymbolProfile: invalid specification for %s (tick=%.10f step=%.4f min=%.4f)", m_symbol,m_tick_size,m_vol_step,m_vol_min); return(false); } //--- decimals of the volume step: 0.01 -> 2, 0.1 -> 1, 1 -> 0 m_vol_digits=(int)MathMax(0.0,MathCeil(-MathLog10(m_vol_step)-1e-9)); return(true); }
La distancia mínima toma el spread del último tick y, si no hay tick, cae al valor de SYMBOL_SPREAD:
double CSymbolProfile::MinStopDistance(const double spread_mult,const double buffer) const { long stops =SymbolInfoInteger(m_symbol,SYMBOL_TRADE_STOPS_LEVEL); long freeze=SymbolInfoInteger(m_symbol,SYMBOL_TRADE_FREEZE_LEVEL); double spread=0.0; MqlTick tick; if(SymbolInfoTick(m_symbol,tick) && tick.ask>tick.bid) spread=tick.ask-tick.bid; else spread=(double)SymbolInfoInteger(m_symbol,SYMBOL_SPREAD)*m_point; //--- the freeze level also matters: a stop placed inside it can never be moved later double broker =(double)MathMax(stops,freeze)*m_point; double floating=spread*spread_mult; double dist =MathMax(MathMax(broker,floating),m_tick_size); return(dist*(1.0+buffer)); }
El redondeo hacia fuera suma o resta un épsilon antes de MathCeil o MathFloor. Sin él, un valor como 1.0841999999998 se redondearía un tick de más por un error de representación:
double CSymbolProfile::RoundAway(const double price,const bool up) const { double ticks=price/m_tick_size; //--- the epsilon absorbs representation errors such as 109999.99999998 ticks=up ? MathCeil(ticks-1e-8) : MathFloor(ticks+1e-8); return(NormalizeDouble(ticks*m_tick_size,m_digits)); }
La pérdida por lote usa OrderCalcProfit y, si el terminal no puede hacer la conversión, recurre al valor del tick para pérdidas:
bool CSymbolProfile::LossPerLot(const ENUM_ORDER_TYPE type,const double entry, const double sl,double &loss) const { loss=0.0; double profit=0.0; if(OrderCalcProfit(type,m_symbol,1.0,entry,sl,profit) && profit<0.0) { loss=-profit; return(true); } //--- fallback through the tick value when the terminal cannot convert double tick_value=SymbolInfoDouble(m_symbol,SYMBOL_TRADE_TICK_VALUE_LOSS); if(tick_value<=0.0) tick_value=SymbolInfoDouble(m_symbol,SYMBOL_TRADE_TICK_VALUE); if(tick_value<=0.0) return(false); loss=MathAbs(entry-sl)/m_tick_size*tick_value; return(loss>0.0); }
La clase también tiene VolumeLimitRoom, que suma el volumen de posiciones y órdenes pendientes en la misma dirección para respetar SYMBOL_VOLUME_LIMIT. DirectionAllowed, por su parte, lee SYMBOL_TRADE_MODE para no intentar una venta en un símbolo que solo admite compras o cierres.
Implementación de CTradeGate
Toda orden de mercado del asesor pasa por CTradeGate::Open. La estrategia solo entrega el tipo de orden y dos distancias en precio. Antes de calcular nada, comprueba los permisos de trading y que el símbolo esté dentro de su sesión. Después, los pasos siguen el orden de la figura 1:
const bool is_buy=(type==ORDER_TYPE_BUY); const double entry=is_buy ? tick.ask : tick.bid; //--- the server measures stops from the price that would close the position const double ref =is_buy ? tick.bid : tick.ask; //--- 1. distances: the broker floor wins, the strategy distance is only widened const double floor_dist=m_sym.MinStopDistance(m_spread_mult,m_buffer); if(sl_dist<floor_dist) sl_dist=floor_dist; if(tp_dist>0.0 && tp_dist<floor_dist) tp_dist=floor_dist; double sl=is_buy ? m_sym.RoundAway(ref-sl_dist,false) : m_sym.RoundAway(ref+sl_dist,true); double tp=0.0; if(tp_dist>0.0) tp=is_buy ? m_sym.RoundAway(ref+tp_dist,true) : m_sym.RoundAway(ref-tp_dist,false); //--- 2. volume from money at risk, computed after the stop is final double loss_per_lot=0.0; if(!m_sym.LossPerLot(type,entry,sl,loss_per_lot)) { Reject(GATE_CHECK_FAILED,"cannot evaluate the loss per lot"); return(false); }
Después vienen el volumen, el tope del lote mínimo y el margen. Fíjate en que todas las correcciones actúan sobre lots y ninguna sobre sl:
double lots=m_sym.FloorVolume(equity*m_risk_pct/100.0/loss_per_lot); if(lots<m_sym.VolumeMin()) { //--- the minimum lot is accepted only while its risk stays under the hard cap double min_risk_pct=100.0*loss_per_lot*m_sym.VolumeMin()/equity; if(min_risk_pct>m_max_risk_pct) { Reject(GATE_RISK_TOO_HIGH,StringFormat("minimum lot %.2f would risk %.2f%% (cap %.2f%%)", m_sym.VolumeMin(),min_risk_pct,m_max_risk_pct)); return(false); } lots=m_sym.VolumeMin(); } //--- 3. margin: shrink the volume, never the stop double margin=0.0; if(!OrderCalcMargin(type,m_sym.Name(),lots,entry,margin)) { Reject(GATE_CHECK_FAILED,StringFormat("OrderCalcMargin failed, error %d",GetLastError())); return(false); } const double free_margin=AccountInfoDouble(ACCOUNT_MARGIN_FREE); if(margin>free_margin*m_margin_use) { if(margin>0.0) lots=m_sym.FloorVolume(lots*free_margin*m_margin_use/margin); if(lots<m_sym.VolumeMin()) { Reject(GATE_NO_MARGIN,StringFormat("free margin %.2f is not enough for %.2f lots", free_margin,m_sym.VolumeMin())); return(false); } }
m_margin_use vale 0.9: la orden solo se envía si consume como mucho el 90 % del margen libre. El margen requerido crece casi en proporción al volumen, así que una regla de tres basta para el ajuste. Al final, la solicitud pasa por OrderCheck antes de OrderSend. OrderCheck valida en el terminal buena parte de lo que comprobaría el servidor (dinero, volumen, niveles de stop), y si falla la solicitud no llega a enviarse ni deja un failed market buy en el registro.
Modify aplica cuatro filtros antes de enviar TRADE_ACTION_SLTP. El primero es la sesión de trading, que explico en los casos límite. Si los niveles nuevos coinciden con los actuales dentro de medio tick, no se envía nada; así se evita TRADE_RETCODE_NO_CHANGES. Si el stop o el objetivo actual ya están dentro de la distancia de congelación, la posición no se puede modificar y tampoco se intenta. Y los niveles nuevos tienen que respetar la misma distancia mínima que en la apertura.
Cada rechazo pasa por Reject, que cuenta el motivo y escribe en el registro como mucho una vez por minuto por motivo. Sin ese límite, una condición que se repite en cada tick llena el registro de miles de líneas en la prueba de M1. Al terminar, PrintSummary escribe una línea como esta:
PT_SUMMARY symbol=GBPUSD# period=M30 signals=495 spread_skips=33 sent=459 done=459 modified=174 no_tick=0 disabled=0 closed=6 risk=0 margin=0 vol_limit=0 stops=3 freeze=0 no_change=0 check=0 send=0
Esa línea es lo que lee el script de la sección de herramientas. Es la línea real de la prueba en GBPUSD# M30 con la versión final de la puerta.
El asesor de ejemplo
PortableBreakoutEA compra cuando el cierre de la última barra supera el máximo de las N barras anteriores y vende en el caso simétrico. La estrategia es deliberadamente simple porque el interés está en la gestión de órdenes. Los parámetros son estos:
input group "Signal" input int InpChannelBars =10; // Breakout channel length (bars) input int InpAtrPeriod =14; // ATR period input group "Exits (multiples of ATR)" input double InpSlAtr =1.5; // Stop loss distance (x ATR) input double InpTpAtr =2.5; // Take profit distance (x ATR), 0 = off input double InpBeTriggerAtr =1.0; // Break-even trigger (x ATR), 0 = off input group "Filters" input double InpMaxSpreadAtr =0.25; // Max spread as a fraction of ATR input group "Risk" input double InpRiskPct =1.0; // Risk per trade (% of equity) input double InpMaxRiskPct =3.0; // Max risk accepted when the minimum lot is forced (%) input group "Execution" input ulong InpMagic =260410; // Magic number input uint InpDeviation =20; // Max deviation (points) input string InpNativeSymbol ="XAUUSD";// Symbol the defaults were tuned on (info only)
No hay ni un solo valor en puntos ni en dinero. El filtro de spread compara el spread con una fracción del ATR, lo que funciona igual con cotizaciones de cinco decimales que con las de dos. InpNativeSymbol solo sirve para escribir un aviso en el registro; el asesor nunca se niega a operar.
Dos detalles de OnTick vienen directamente de la prueba en XAUUSD D1. Primero, el asesor comprueba que haya barras suficientes y que el ATR esté calculado antes de marcar la barra como procesada. Si la historia aún no alcanza, sale y lo vuelve a intentar en el siguiente tick, en lugar de perder la barra:
void OnTick() { datetime bar=iTime(_Symbol,_Period,0); if(bar==0 || bar==g_last_bar) return; //--- short history (fresh symbol, D1 in a short test window): wait, do not fail if(Bars(_Symbol,_Period)<InpChannelBars+InpAtrPeriod+2) return; if(BarsCalculated(g_atr_handle)<InpAtrPeriod+2) return; double atr[]; if(CopyBuffer(g_atr_handle,0,1,1,atr)!=1 || atr[0]<=0.0) return; g_atr=atr[0]; g_last_bar=bar; //--- manage first, then look for a new entry ulong ticket=0; if(FindPosition(ticket)) { ManageBreakEven(ticket); return; } CheckEntry(); }
Segundo, el canal es de 10 barras y no de 20 o 50. En D1, un canal largo puede no romperse nunca dentro de la ventana corta de la validación, y eso termina en el mismo mensaje de que no hubo operaciones. Lo cuento porque afecta a cualquier estrategia que se vaya a publicar: la frecuencia de señales en el marco temporal más lento de la validación también hay que revisarla.
El punto de equilibrio se gestiona una vez por barra. ManageBreakEven no llama a Modify si el stop ya está en el precio de entrada o mejor. Si la entrada está demasiado cerca del precio actual para el nivel de stops del bróker, la decisión queda en manos de la puerta.
Herramientas: mensajes no ASCII y matriz de pruebas local
check_non_ascii.py
El script recorre archivos .mq5, .mqh y .mq4, detecta si están en UTF-8 o en UTF-16 (MetaEditor usa los dos) y clasifica cada carácter no ASCII según dónde aparece: dentro de una cadena, en el comentario de una línea input, en un comentario normal o en código. Los dos primeros casos son errores; los comentarios normales solo se informan. Con un archivo de prueba que tenía un input en español, una línea con dos cadenas acentuadas y tres comentarios, la salida fue:
ERROR bad.mq5:1:52 U+00F3 'ó' in input_comment ERROR bad.mq5:5:29 U+00F1 'ñ' in string ERROR bad.mq5:5:62 U+00F1 'ñ' in string info bad.mq5:2:26 U+00F1 'ñ' in comment info bad.mq5:3:15 U+00E1 'á' in comment info bad.mq5:4:5 U+00E1 'á' in comment 1 file(s) scanned, 3 error(s), 3 comment note(s)
Sobre los tres archivos fuente de este artículo devuelve cero errores. Lo corro antes de cada envío al Market.
validation_lab.py
El comando make genera un archivo .ini por escenario y un run_all.bat que lanza el terminal con el parámetro /config para cada uno. Por defecto crea los cuatro escenarios con dos depósitos, 10000 y 100 USD, porque el depósito que usa la validación no está publicado y conviene ver qué pasa con poco capital:
python validation_lab.py make --expert "PortableTrade\PortableBreakoutEA" --terminal "C:\Program Files\MetaTrader 5\terminal64.exe" --out lab
Cada archivo usa la sección [Tester] documentada en la ayuda de MetaTrader 5, con ShutdownTerminal=1 para que el terminal se cierre al terminar la prueba y el .bat pase a la siguiente. El modo de cobertura o compensación lo define la cuenta con la que está conectado el terminal, así que hay que ejecutarlo con una cuenta demo de cobertura para imitar la validación.
El comando scan lee los registros del agente del probador (están en UTF-16), extrae las líneas PT_SUMMARY y cuenta los patrones que la validación convierte en errores: invalid stops, invalid volume, not enough money, invalid price, market closed, array out of range y zero divide. Si una prueba termina con done=0, lo marca como el caso de there are no trading operations.
python validation_lab.py scan --logs "C:\Users\...\Tester\...\Agent-127.0.0.1-3000\logs" --last 1
Casos límite
Mercado cerrado: el caso que OrderCheck no detecta
Este caso no estaba en mi lista inicial. Apareció en la primera ronda de pruebas: en GBPUSD# M30 el asesor intentó operar en barras que abrían a las 23:30 del viernes y a las 00:00 del lunes. OrderCheck aprobó las solicitudes, porque no revisa las sesiones de trading, y OrderSend las rechazó con el código 10018 (Market closed). Fueron cinco rechazos en seis meses, suficientes para que la validación marque la prueba con errores.
La solución fue añadir a CSymbolProfile una comprobación de sesión con SymbolInfoSessionTrade. CTradeGate la llama antes de abrir y antes de modificar, con un margen de 60 segundos respecto al inicio y al final de cada sesión:
bool CSymbolProfile::SessionOpen(const int margin_sec) const
{
MqlDateTime now;
TimeToStruct(TimeCurrent(),now);
const int secs=now.hour*3600+now.min*60+now.sec;
datetime from=0,to=0;
uint index=0;
while(SymbolInfoSessionTrade(m_symbol,(ENUM_DAY_OF_WEEK)now.day_of_week,index,from,to))
{
//--- session bounds are seconds from the start of the day (to may be 86400)
if(secs>=(int)from+margin_sec && secs<(int)to-margin_sec)
return(true);
index++;
}
//--- a symbol without any published session is treated as always open
if(index==0)
{
bool any=false;
for(int d=0; d<7 && !any; d++)
any=SymbolInfoSessionTrade(m_symbol,(ENUM_DAY_OF_WEEK)d,0,from,to);
return(!any);
}
return(false);
}
XAUUSD D1 con un depósito pequeño
Es el escenario más difícil de pasar honestamente. El ATR diario del oro se mide en decenas de dólares de precio y el lote mínimo de 0.01 suele equivaler a una onza. Con un stop de 1.5 veces el ATR, una sola operación con el lote mínimo puede arriesgar más que todo el saldo de una cuenta pequeña. En mis pruebas con 100 USD, el lote mínimo arriesgaba alrededor del 145 % de la cuenta, y la puerta rechazó la orden con el motivo risk.
Aquí hay que elegir. Se puede subir InpMaxRiskPct, ajustar el stop en D1 o aceptar que el asesor no opere con esa combinación. Lo que no recomiendo es encoger el stop hasta que el lote mínimo entre, porque entonces el stop deja de tener relación con la volatilidad. La línea PT_SUMMARY muestra cuántas señales se rechazaron por este motivo y permite tomar la decisión con datos.
Nivel de stops igual a cero
Muchos brókers ECN publican SYMBOL_TRADE_STOPS_LEVEL igual a cero y aplican un mínimo flotante ligado al spread. Por eso MinStopDistance usa dos veces el spread como piso. En el probador el spread del escenario M1 puede ser comparable al ATR, y en ese caso el piso domina sobre la distancia de la estrategia.
Cuentas de compensación (netting)
FindPosition busca por símbolo y número mágico, lo que funciona en cobertura y en compensación. En compensación una nueva orden en sentido contrario cerraría o invertiría la posición existente; el asesor lo evita porque no abre si ya tiene una posición.
Símbolos con sufijo
El aviso de símbolo nativo usa StringFind, así que XAUUSD.m o XAUUSDm se reconocen como el mismo instrumento. El script validation_lab.py tiene la opción --suffix para generar los escenarios con el nombre de símbolo del bróker. En XM, por ejemplo, el oro se llama GOLD#; ahí InpNativeSymbol no lo reconoce, el asesor escribe el aviso en el registro y opera igual.
Órdenes pendientes
La puerta solo maneja órdenes de mercado. Si tu asesor usa pendientes, además hay que comprobar ACCOUNT_LIMIT_ORDERS y medir el nivel de stops desde el precio de la orden pendiente y no desde el Bid o el Ask. Lo dejo fuera para no alargar el ejemplo.
Notas de prueba
Qué se probó: la compilación de los tres archivos en MetaEditor (0 errores, 0 advertencias) y las ocho ejecuciones que genera validation_lab.py, es decir, cuatro escenarios con depósitos de 10000 y 100 USD. Las pruebas se hicieron en una cuenta demo de cobertura de XM, con el modelo de todos los ticks, apalancamiento 1:100 y un retraso de ejecución de 191 ms, del 2026.04.01 al 2026.09.30. En ese bróker el oro se llama GOLD# y las divisas llevan el sufijo #. Los dos scripts de Python se probaron con archivos de ejemplo en UTF-8 y UTF-16, y el comando scan también se probó sobre el registro real de estas ejecuciones.
| Escenario | Depósito | Señales | Ejecutadas | Rechazos de la puerta | Errores en el registro |
|---|---|---|---|---|---|
| EURUSD# H1 | 10000 | 253 | 251 | stops 2, closed 1 | 0 |
| GOLD# D1 | 10000 | 7 | 7 | stops 1 | 0 |
| GBPUSD# M30 | 10000 | 495 | 459 | closed 6, stops 3 | 0 |
| EURUSD# M1 | 10000 | 32300 | 112 | stops 2 | 0 |
| EURUSD# H1 | 100 | 266 | 237 | risk 26, stops 2, closed 1 | 0 |
| GOLD# D1 | 100 | 14 | 0 | risk 14 | 0 |
| GBPUSD# M30 | 100 | 566 | 426 | risk 95, closed 9, stops 3 | 0 |
| EURUSD# M1 | 100 | 32300 | 112 | stops 2 | 0 |
En la columna de señales, la diferencia con las ejecutadas se reparte entre los rechazos de la puerta y el filtro de spread. En EURUSD# M1 el filtro descartó 32188 de las 32300 señales: en M1 el ATR es tan pequeño que un spread de un par de décimas de pip ya supera el 25 % del ATR. Aun así quedaron 112 operaciones, que es lo que importa para la validación. Los rechazos por stops corresponden a intentos de mover el stop al punto de equilibrio cuando la entrada estaba demasiado cerca del precio; la puerta no los envía.

Figura 2. Registro del probador en GBPUSD# M30 con la versión final de la puerta
La única prueba sin operaciones fue GOLD# D1 con 100 USD, que es exactamente el caso límite descrito arriba: el lote mínimo arriesgaba alrededor del 145 % de la cuenta. Con ese depósito la validación devolvería there are no trading operations en esa prueba. No sé qué depósito usa el Market en sus pruebas, así que antes de publicar un asesor conviene decidir qué hacer con este caso, por ejemplo subir InpMaxRiskPct o ajustar el stop en D1.
Qué no se probó: la rentabilidad de la estrategia, que no es el objeto del artículo (las cuentas terminaron entre 46.92 y 9924.52 USD); cuentas con depósito en monedas distintas del USD; símbolos con ejecución por bolsa; y el comportamiento con órdenes pendientes. Tampoco puedo asegurar que la validación del Market use exactamente estos escenarios, porque MetaQuotes puede cambiarlos sin aviso. Lo que sí se puede afirmar es que, si alguno de estos ocho escenarios falla en local, el mismo problema aparecerá en la validación.
Conclusión
Los rechazos de la validación automática que más tiempo me han hecho perder salieron de supuestos sobre el símbolo escondidos en el código, y la lógica de trading estaba bien. La mayoría de los Invalid stops y de las pruebas sin operaciones se resuelven con tres cambios: expresar distancias y filtros en unidades de volatilidad, calcular el stop antes que el volumen y reducir siempre el volumen en lugar del stop. Lo que queda, como el riesgo del lote mínimo en D1 con poco capital, es una decisión de diseño que conviene tomar a la vista y no descubrir en el informe de la validación.
CSymbolProfile y CTradeGate no dependen de la estrategia, así que se pueden incluir en cualquier asesor existente sustituyendo las llamadas directas a CTrade u OrderSend. Los dos scripts sirven para comprobar el resultado antes de cada envío.
Archivos adjuntos
| Archivo | Descripción |
|---|---|
| MQL5\Include\PortableTrade\SymbolProfile.mqh | Redondeo de precios y volúmenes, distancia mínima de stop y pérdida por lote |
| MQL5\Include\PortableTrade\TradeGate.mqh | Puerta de órdenes de mercado y modificaciones con contadores de rechazo |
| MQL5\Experts\PortableTrade\PortableBreakoutEA.mq5 | Asesor de ejemplo con parámetros en unidades de ATR |
| tools\check_non_ascii.py | Busca caracteres no ASCII en cadenas y comentarios de parámetros |
| tools\validation_lab.py | Genera los escenarios del probador y resume los registros |


