A practical, code-first walkthrough of attaching a real stop-loss to a MetaTrader Expert Advisor in both MQL4 and MQL5, covering fixed and ATR-based stops plus the broker stops-level / error 130 gotcha. This is educational content only, not financial advice; trading carries a real risk of loss, and every example should be tested on a demo account first before it ever touches a live one.
The scariest bug in an Expert Advisor is not the one that throws an error. It is the one that compiles cleanly, back-tests without complaint, and quietly opens a position with no stop-loss. A naked position has no automatic exit on the losing side, so a single gap, news spike, or overnight move can run against you indefinitely until margin gives out. The code looks finished because it does exactly what you typed. The problem is what you forgot to type.
In MetaTrader, a stop-loss is not a separate order you place later. It is a price level attached to the position itself, and it is one of the arguments to the order-sending call. If you leave that argument at 0.0, the platform interprets it as "no stop-loss" and happily submits the order. There is no warning, no compile error, no runtime complaint. This is why so many first EAs blow up in forward testing: the logic for entering was correct, but the safety net was literally an empty function parameter.
The fix is a discipline, not a feature: treat a non-zero stop-loss as mandatory on every market order. Throughout this guide, every example computes a real stop price before sending, refuses to trade if that price cannot be validated, and verifies the trade result afterwards. Getting an mql stop loss expert advisor right is mostly about never letting a 0.0 slip through. As a rule of thumb, if you can find a code path in your EA that reaches OrderSend (MQL4) or trade.Buy (MQL5) without a positive stop-loss, you have found a live-account hazard.
This is educational material, not a strategy recommendation. A stop-loss controls where you exit a losing trade; it does not make a system profitable, and it can still be skipped over by slippage or gaps. Everything below should be validated on a demo account before you even think about real capital.
In MQL4 the stop-loss lives in the 6th positional argument of OrderSend. The signature is OrderSend(symbol, cmd, volume, price, slippage, stoploss, takeprofit, ...). Because it is positional, it is easy to lose track of which value belongs where — and a stop-loss placed on the wrong side of price is one of the most common causes of the infamous error 130 (invalid stops).
A correct MQL4 buy needs the stop below the entry price and a sell needs it above. You also have to respect Point and Digits so a 20-pip stop is actually 20 pips on a 5-digit broker, not 2 pips. The example below gates entry to a new bar, opens at most one position (checked via magic number), reads the lot from an input, computes a real stop from a pip input, sends the order, and checks the result. Note the entry decision is read from the last closed bar (shift 1), never the still-forming shift 0 candle.
4
// --- inputs ---
extern double LotSize = 0.10;
extern int StopLossPips = 20;
extern int Slippage = 3;
extern int MagicNumber = 20240517;
datetime g_lastBarTime = 0;
// count our own open positions on this symbol/magic
int CountMyOrders() {
int n = 0;
for (int i = OrdersTotal() - 1; i >= 0; i--) {
if (!OrderSelect(i, SELECT_BY_POS, MODE_TRADES)) continue;
if (OrderSymbol() == Symbol() && OrderMagicNumber() == MagicNumber)
n++;
}
return n;
}
void OnTick() {
// 1) act only once per new bar
if (Time[0] == g_lastBarTime) return;
g_lastBarTime = Time[0];
// 2) one position at a time
if (CountMyOrders() > 0) return;
// 3) example signal, read from the last CLOSED bar (shift 1)
bool goLong = Close[1] > iMA(Symbol(), 0, 50, 0, MODE_SMA, PRICE_CLOSE, 1);
if (!goLong) return;
// 4) build a REAL, non-zero stop-loss below entry
double pip = Point;
if (Digits == 3 || Digits == 5) pip = Point * 10.0; // pip vs point
double ask = Ask;
double sl = NormalizeDouble(ask - StopLossPips * pip, Digits);
// 5) send and VERIFY
int ticket = OrderSend(Symbol(), OP_BUY, LotSize, ask, Slippage,
sl, 0, "MA cross", MagicNumber, 0, clrBlue);
if (ticket < 0) {
int err = GetLastError();
Print("OrderSend failed, error ", err); // 130 = invalid stops
} else {
Print("Buy opened, ticket ", ticket, " SL=", sl);
}
}Two details do the heavy lifting here. The pip conversion makes StopLossPips broker-agnostic, and the if (ticket < 0) check means a rejected order is logged with its real error code instead of vanishing silently. If you see 130 in the log, jump to the stops-level section below — it is almost never a bug in your maths and almost always the broker's minimum distance.
MQL5 replaced positional OrderSend with a structured request. You fill a MqlTradeRequest, call OrderSend(request, result), and read the outcome from result.retcode. The stop-loss is the named field request.sl, which is far harder to misplace than a 6th positional argument. Because MQL5 is order-driven, remember the distinction: an order becomes a position, and the stop-loss travels with the position.
Most people never touch the raw request, though, because the standard library ships a CTrade wrapper that handles the boilerplate. The example below uses CTrade, sets a magic number, reads price from SymbolInfoDouble, pulls the confirmed signal from the last closed bar via CopyRates, computes a non-zero stop, and checks trade.ResultRetcode() against TRADE_RETCODE_DONE.
5
#include <Trade\Trade.mqh>
input double InpLot = 0.10;
input int InpSLPoints = 200; // stop distance in POINTS
input ulong InpMagic = 20240517;
CTrade trade;
datetime g_lastBar = 0;
int OnInit() {
trade.SetExpertMagicNumber(InpMagic);
trade.SetDeviationInPoints(10);
return(INIT_SUCCEEDED);
}
void OnTick() {
// 1) new-bar gate
datetime t = iTime(_Symbol, _Period, 0);
if (t == g_lastBar) return;
g_lastBar = t;
// 2) one position per symbol
if (PositionSelect(_Symbol)) return;
// 3) confirmed signal from the last CLOSED bars (shift 1 and 2)
MqlRates r[];
if (CopyRates(_Symbol, _Period, 1, 2, r) < 2) return;
// r[1] = last closed bar (shift 1), r[0] = the bar before it (shift 2)
bool goLong = r[1].close > r[0].close; // trivial example only
if (!goLong) return;
// 4) build a REAL, non-zero stop-loss below entry
double ask = SymbolInfoDouble(_Symbol, SYMBOL_ASK);
double sl = NormalizeDouble(ask - InpSLPoints * _Point, _Digits);
// 5) send via CTrade and VERIFY
if (!trade.Buy(InpLot, _Symbol, ask, sl, 0.0, "MA example")) {
Print("Buy() call failed: ", trade.ResultRetcode(),
" ", trade.ResultRetcodeDescription());
return;
}
if (trade.ResultRetcode() != TRADE_RETCODE_DONE)
Print("Order not filled, retcode: ", trade.ResultRetcode());
else
Print("Buy done, SL=", sl);
}Do not mix dialects. MQL5 uses CTrade, ORDER_TYPE_*, PositionSelect, and CopyRates; MQL4 uses positional OrderSend, OP_BUY, and OrderSelect. Code that pastes MQL4's OrderSend(Symbol(), OP_BUY, ...) into an .mq5 file will not compile, and Frankenstein snippets stitched from both are a common reason a stop-loss silently ends up as 0.0. Pick one target per EA and stay in it.
A fixed 20-pip stop may be tight on a calm major and dangerously loose on a volatile cross during a news hour. An ATR-based stop scales the distance to recent volatility: measure the Average True Range, multiply it by a factor (commonly 1.5 to 3), and place the stop that far from entry. When the market is quiet the stop tightens; when it is wild the stop widens, which can reduce the odds of getting knocked out by ordinary noise — though it changes nothing about whether the underlying idea has an edge.
The mechanics are identical to the fixed case — you still put a non-zero price in the stop-loss field — you just compute the distance differently. Read ATR from the last closed bar, not the live one, so the value does not shift mid-candle. Here is the MQL5 handle-based approach, which is the correct idiom (iATR returns a handle you read with CopyBuffer, not a value):
5
#include <Trade\Trade.mqh>
input double InpLot = 0.10;
input int InpAtrLen = 14;
input double InpAtrMult = 2.0;
input ulong InpMagic = 20240518;
CTrade trade;
int g_atrHandle = INVALID_HANDLE;
datetime g_lastBar = 0;
int OnInit() {
trade.SetExpertMagicNumber(InpMagic);
g_atrHandle = iATR(_Symbol, _Period, InpAtrLen);
if (g_atrHandle == INVALID_HANDLE) return(INIT_FAILED);
return(INIT_SUCCEEDED);
}
void OnTick() {
datetime t = iTime(_Symbol, _Period, 0);
if (t == g_lastBar) return;
g_lastBar = t;
if (PositionSelect(_Symbol)) return;
// read ATR from the last CLOSED bar (shift 1)
double atr[];
if (CopyBuffer(g_atrHandle, 0, 1, 1, atr) < 1) return;
double stopDist = InpAtrMult * atr[0];
if (stopDist <= 0) return; // never send a zero-distance stop
double ask = SymbolInfoDouble(_Symbol, SYMBOL_ASK);
double sl = NormalizeDouble(ask - stopDist, _Digits);
if (!trade.Buy(InpLot, _Symbol, ask, sl, 0.0, "ATR stop")) {
Print("Buy failed: ", trade.ResultRetcode());
return;
}
if (trade.ResultRetcode() == TRADE_RETCODE_DONE)
Print("Buy done, ATR SL=", sl, " dist=", stopDist);
else
Print("Order not filled, retcode: ", trade.ResultRetcode());
}The if (stopDist <= 0) return; guard matters: if the ATR buffer read fails or returns garbage, you must not fall through and send an order with a zero or negative stop distance. An ATR stop makes your risk consistent in volatility terms, but note it does not keep your monetary risk constant — a wider stop with the same lot size means a larger cash loss if it is hit. If you want fixed cash risk, you would size the lot down as the ATR stop widens, which is a separate (and important) topic.
Here is the wall almost every EA author hits: you compute a perfectly reasonable stop, send the order, and get error 130 (invalid stops) in MQL4 or retcode 10016 (invalid stops) in MQL5. Your maths is fine. The problem is the broker's minimum stop distance — the SYMBOL_TRADE_STOPS_LEVEL — which forbids placing a stop closer than N points to the current price. If your stop lands inside that forbidden band, the server rejects the whole order.
The fix is to query the stops level and clamp your stop distance so it is never tighter than the broker allows. You should also account for the freeze level in some cases, but the stops level is the one that bites first. Here is a defensive MQL5 helper that enforces a floor on the stop distance:
5
// Returns a buy stop-loss price that respects the broker's minimum.
double SafeBuyStop(double entry, double desiredDist) {
long stopsLevel = SymbolInfoInteger(_Symbol, SYMBOL_TRADE_STOPS_LEVEL);
double minDist = stopsLevel * _Point;
// never tighter than the broker's minimum distance
double dist = MathMax(desiredDist, minDist);
// one extra point of safety margin against rounding
dist += _Point;
return NormalizeDouble(entry - dist, _Digits);
}A few closing habits that keep stops out of trouble. Always NormalizeDouble your stop to _Digits (MQL5) or Digits (MQL4) — an unrounded price with too many decimals is itself a common cause of 130. Always send the stop with the order rather than opening naked and adding the stop on a later tick; the gap between the two is exactly when a fast move can wreck you. And always test on a demo account first, because the stops level and spread you see live can differ from the Strategy Tester's idealized fills. For a deeper pre-live pass on this class of bug, ForexCodes' EA Auditor can flag naked positions and unverified sends automatically, but the manual checklist in this article catches the majority of them on its own.
One last reminder, because it is the whole point: none of this makes a strategy profitable. A stop-loss is a risk-control mechanism, not an edge. It defines your worst-case exit and nothing more. This article is educational only and is not financial advice; trading carries a genuine risk of loss, and every example here belongs on a demo account until you have watched it behave correctly across many bars and market conditions.
Educational only — not financial advice. Trading involves substantial risk of loss.