A few years back I had an EA with a 68% win rate in the tester. It looked fantastic. I dropped it on a small live account at a flat 0.20 lots per trade, left it running, and came back a week later to a 41% drawdown. The signals had behaved – roughly what the backtest promised. What drained the account was that I’d hard-coded the lot size and paid no attention to how far the stop sat from entry. A few wide-stop losers in a row, every one of them sized exactly like the tight-stop winners, and the arithmetic did the rest.
That week taught me the thing the “code your first EA” tutorials skip: the entry signal is the part you’ll fuss over endlessly and the part that matters least. Where you put the stop and how big you trade decide whether you’re still around next month. So here’s the money-management core I now bolt onto every EA before I write a single line of entry logic.
Size off the stop, not off the balance
The mistake almost everyone makes early on is picking a lot size first – 0.10, 0.20, whatever feels right – and using it on every trade. Your real risk then swings wildly, because a trade with a 60-pip stop loses three times what a 20-pip stop loses at the same volume.
The fix is to decide the money first and let the lot fall out of the stop distance. Risk a fixed slice of the account per trade, say 1%, then solve for the volume that makes a full-stop loss equal to that slice.
Concrete numbers, because this is where it clicks. Account at $5,000, risking 1%, so $50 on the line. On EURUSD a 25-pip stop is a price distance of 0.0025; one standard lot loses about $10 per pip, so 25 pips is roughly $250 a lot. Fifty dollars divided by $250 gives 0.20 lots. Now widen the stop to 60 pips on the next setup: that’s $600 a lot, and the same $50 budget buys you only 0.08 lots. Same risk in dollars, very different position – which is exactly what you want.
Here’s the function that does it. Note that it works off tick value and tick size rather than a hard-coded pip value, and that detail matters more than it looks.
//+------------------------------------------------------------------+
//| Lot size from risk percent and stop distance (in price units) |
//+------------------------------------------------------------------+
double CalculateLotSize(double riskPercent, double slDistancePrice)
{
double balance = AccountInfoDouble(ACCOUNT_BALANCE);
double riskMoney = balance * riskPercent / 100.0;
double tickValue = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_VALUE);
double tickSize = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_SIZE);
if(slDistancePrice <= 0 || tickValue <= 0 || tickSize <= 0)
return 0.0;
// What one full lot loses if price travels the whole stop distance
double lossPerLot = (slDistancePrice / tickSize) * tickValue;
double lots = riskMoney / lossPerLot;
return NormalizeLot(lots);
}
Why tick value instead of a fixed “pip = $10”? Because $10 is only true for a standard lot on a USD-quoted pair. Trade USDJPY and the pip value floats with the USDJPY rate. Trade XAUUSD or an index CFD and it’s a completely different number again. SYMBOL_TRADE_TICK_VALUE already returns the value of one tick for one lot in your account currency, so the same function sizes EURUSD, gold and US500 correctly without a single special case. Hard-code the pip value and your gold trades will be mis-sized by an order of magnitude.
One judgement call worth flagging: I size off balance, not equity. Sizing off equity shrinks your positions automatically as an open drawdown deepens, which sounds prudent but quietly turns a basket of correlated losers into a slow bleed that never recovers. Balance-based sizing keeps the risk-per-trade honest. Reasonable people disagree here – just know which one you picked and why.
Why the broker keeps rejecting your volume
Run the function above and sooner or later it spits out something like 0.1374 lots. Send that and the server fires back error 10014, invalid volume, and your trade never happens. Every symbol has a minimum volume, a maximum, and a step, and your number has to land exactly on the grid.
//+------------------------------------------------------------------+
//| Round a raw lot to the broker's allowed volume grid |
//+------------------------------------------------------------------+
double NormalizeLot(double lots)
{
double minLot = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MIN);
double maxLot = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MAX);
double lotStep = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_STEP);
lots = MathFloor(lots / lotStep) * lotStep; // round DOWN to the step
lots = MathMax(minLot, MathMin(maxLot, lots)); // clamp to the broker range
return NormalizeDouble(lots, 2);
}
Round down, never up. Rounding up nudges you over your risk budget on every single trade, and the whole point was to stay under it. Watch the step too: plenty of retail brokers use a 0.01 step, but some accounts and some CFD symbols use 0.1, and a few crypto CFDs have steps you’d never guess. The clamp to minLot also quietly tells you something – if your 1% risk works out smaller than the minimum tradable lot, you’re under-capitalised for that stop on that symbol, and the honest move is to skip the trade rather than take 0.01 lots and pretend the math held.
The stop the docs barely mention: stops level and freeze level
This is the one that cost me an afternoon of confusion, so I’ll save you the trouble. You calculate a perfectly sensible stop, send the order, and get error 10016 – invalid stops. The price was fine. The problem is that brokers enforce a minimum distance between the current price and any stop or limit you attach, and if your stop sits inside that band the server refuses it.
That distance lives in SYMBOL_TRADE_STOPS_LEVEL, measured in points. There’s a sibling, SYMBOL_TRADE_FREEZE_LEVEL, which is the band inside which you can’t modify or close an order at all because it’s too close to triggering. Most demo servers report zero for both, which is exactly why an EA that ran clean on demo suddenly throws 10016 on a live account where the broker sets them to 30 or 50 points.
//+------------------------------------------------------------------+
//| Smallest stop distance the broker will accept, in price units |
//+------------------------------------------------------------------+
double MinStopDistance()
{
long stopsLevel = SymbolInfoInteger(_Symbol, SYMBOL_TRADE_STOPS_LEVEL);
double point = SymbolInfoDouble(_Symbol, SYMBOL_POINT);
return stopsLevel * point;
}
Before you place anything, make sure your stop distance is at least this wide, and widen it if it isn’t. A volatility-based stop usually clears the level comfortably, but during a dead session at 2am server time the ATR can collapse and push your stop right into the forbidden band.
ECN accounts: open first, attach the stop after
On a lot of market-execution and ECN setups, the server won’t accept a stop loss or take profit in the same request that opens the position. You send entry plus SL plus TP, and it bounces the whole thing. The pattern that works is two steps: open naked, then modify the position to add the stop once it exists.
// Market-execution friendly: open with no SL/TP, then attach them
if(trade.Buy(lots, _Symbol, ask, 0.0, 0.0, "entry"))
{
ulong ticket = trade.ResultOrder();
if(PositionSelectByTicket(ticket))
trade.PositionModify(ticket, sl, 0.0);
}
You can detect which regime you’re on by reading SYMBOL_TRADE_EXEMODE instead of guessing, but in practice I just code defensively and attach stops in a second step everywhere. It costs nothing on instant-execution brokers and saves you on the ones that demand it. The only real risk is the gap between the fill and the modify – if you’re trading something fast, keep that window as tight as you can.
Let the stop breathe with ATR
A fixed 20-pip stop is fine in a sleepy London morning and gets you stopped out on noise the moment news hits. Tying the stop to Average True Range lets it widen and tighten with the market on its own.
The trap here catches a lot of people: iATR does not return pips. It returns a value in the symbol’s price units. On 5-digit EURUSD an ATR reading of 0.00120 is 12 pips, not 120. Treat the buffer value as a price distance and feed it straight into the sizing function and everything stays consistent.
int atrHandle;
int OnInit()
{
atrHandle = iATR(_Symbol, _Period, 14);
if(atrHandle == INVALID_HANDLE)
return INIT_FAILED; // bail now, not on the first tick
return INIT_SUCCEEDED;
}
// Stop distance in PRICE units, never smaller than the broker's stops level
double AtrStopDistance(double multiplier)
{
double atr[];
if(CopyBuffer(atrHandle, 0, 0, 1, atr) <= 0)
return 0.0; // indicator not ready yet
double distance = atr[0] * multiplier;
return MathMax(distance, MinStopDistance());
}
Create the handle once in OnInit and reuse it. Calling iATR on every tick leaks handles and will eventually slow the EA to a crawl – a classic beginner mistake that doesn’t show up in a five-minute test but bites in a long backtest.
Putting the pieces together
Now the four functions click into one entry. The same stop distance feeds both the stop price and the lot size, so they can never drift out of sync, and a margin check stops the EA from firing an order the account can’t actually support.
#include <Trade/Trade.mqh>
CTrade trade;
input double InpRiskPercent = 1.0; // Risk per trade, percent of balance
input double InpAtrMult = 2.0; // Stop = ATR * this
void OpenBuy()
{
double ask = SymbolInfoDouble(_Symbol, SYMBOL_ASK);
double slDistance = AtrStopDistance(InpAtrMult);
if(slDistance <= 0)
return;
double sl = ask - slDistance;
double lots = CalculateLotSize(InpRiskPercent, slDistance);
if(lots <= 0)
return;
// Don't send what the account can't carry
double margin;
if(!OrderCalcMargin(ORDER_TYPE_BUY, _Symbol, lots, ask, margin) ||
margin > AccountInfoDouble(ACCOUNT_MARGIN_FREE))
return;
if(trade.Buy(lots, _Symbol, ask, 0.0, 0.0, "risk-managed entry"))
{
ulong ticket = trade.ResultOrder();
if(PositionSelectByTicket(ticket))
trade.PositionModify(ticket, sl, 0.0);
}
}
The mistakes that cost me real money
Fixed lots regardless of stop distance – that’s the 41% week, and it’s the most expensive lesson in the list.
Assuming pip value is constant across symbols. My gold trades were sized as if XAUUSD moved like EURUSD. It does not.
Ignoring stops level until a live broker threw 10016 at me and I lost an afternoon blaming my price math when the price was never the problem.
Trusting the tester’s stop fills. If you run the Strategy Tester on the “open prices only” model, your stop appears to fill at neat, flattering levels that real ticks never deliver. Run “every tick based on real ticks” before you believe any drawdown number, and even then treat it as optimistic.
The next piece I add, once this core is solid, is a trailing stop – letting winners stretch while this same risk cap holds the downside. That’s the follow-up post. For now, drop these four functions into your next EA before you write the entry, and put it through the tester on real ticks so the losses you see on screen are at least honest ones.
