Why ATR Position Sizing Matters More Than You Think
Most EA developers I meet start with fixed lot sizes. 0.1 lots, maybe 0.5 if they're feeling aggressive. It works fine in backtests on clean trend days. Then a volatility spike hits — GBPJPY gaps 200 pips overnight — and suddenly that 0.1 lot position is losing $400 when you expected $100. The EA blows through its drawdown limit and stops trading.
That's the core problem: price volatility is not constant. A fixed lot size ignores market conditions entirely. You're essentially betting the same amount whether the market is quiet as a library or wild as a crypto exchange. ATR position sizing solves this by adjusting your trade size based on current volatility. When the market quiets down, you size up to capture the same risk-adjusted return. When it gets choppy, you scale back to preserve capital.
I remember a specific incident from early 2020. I had a fixed-lot EA running on USDJPY that had been grinding out steady profits for months. When COVID volatility hit in March, that same EA took a 15% drawdown in three days — not because the strategy was bad, but because the lot size didn't adapt. After switching to ATR-based sizing, the same strategy's max drawdown on that period dropped to 4.2%. That's the difference a few lines of code can make.
In this post, I'll show you exactly how to code a reusable ATR-based position sizing function in MQL5. No fluff, no theory without application — just the function, the logic, and the edge cases I've hit in production EAs over the last few years. You'll walk away with a function you can drop into any EA today.
What ATR Position Sizing Actually Does
Average True Range (ATR) measures market volatility over a lookback period — typically 14 bars. Developed by J. Welles Wilder Jr. for commodities, it's become a staple in forex and CFD trading. A higher ATR means wider price swings; lower ATR means tighter ranges. The key insight for position sizing is that a stop loss set at, say, 2x ATR will contain roughly the same percentage of price movement regardless of market conditions.
Instead of risking a fixed dollar amount per trade (which many traders do with percentage-based models), ATR sizing ties your position size to the stop distance measured in ATR units. The formula is straightforward:
LotSize = (AccountBalance * RiskPercent / 100) / (StopLossATR * ATR_Value * TickValuePerLot)Where:
- AccountBalance — your current equity or balance (your choice)
- RiskPercent — what percentage of account you're willing to lose if stopped out
- StopLossATR — how many ATR units your stop is away from entry (e.g., 1.5)
- ATR_Value — the current ATR reading for your timeframe
- TickValuePerLot — how much one tick movement is worth per standard lot
This gives you a position size that adjusts dynamically. When volatility doubles, your lot size halves — keeping your dollar risk exactly where you want it. Let me give you a concrete example. Suppose you have a $10,000 account, risk 1% per trade ($100), and your stop is 2x ATR. If ATR is 50 pips, your stop is 100 pips. With a tick value of $1 per pip per standard lot, your lot size is $100 / (100 pips * $1) = 1.0 lot. Now if ATR jumps to 100 pips, your stop becomes 200 pips, and your lot size drops to $100 / (200 * $1) = 0.5 lots. Same dollar risk, half the exposure.
One nuance: the tick value varies by broker and symbol. For forex majors on standard accounts, one pip on one standard lot is roughly $10, but for indices like US30 or commodities like XAUUSD, the tick value calculation is different. You'll need to pull SYMBOL_TRADE_TICK_VALUE from the symbol info — which we do in the function below. I've seen EAs hardcode a pip value of $10 and then fail miserably on JPY pairs or CFDs. Always use the broker's reported tick value.
Building the MQL5 Position Sizing Function
Let's get into the code. I'll write this as a standalone function you can drop into any EA. It handles the core calculation plus the MQL5-specific quirks like symbol tick value and lot step rounding. I've refactored this function about six times over the years, so what you see below is battle-tested.
//+------------------------------------------------------------------+
//| ATR-based position sizing function |
//| Returns dynamic lot size based on account risk and current ATR |
//+------------------------------------------------------------------+
double ATRPositionSizing(string symbol,
double riskPercent,
int atrPeriod,
double atrMultiplier)
{
// Get current ATR value
double atrArray[];
ArraySetAsSeries(atrArray, true);
int atrHandle = iATR(symbol, PERIOD_CURRENT, atrPeriod);
if(atrHandle == INVALID_HANDLE)
{
Print("Failed to create ATR handle for ", symbol, " error: ", GetLastError());
return 0.0;
}
if(CopyBuffer(atrHandle, 0, 0, 1, atrArray) < 1)
{
Print("Failed to copy ATR buffer for ", symbol, " error: ", GetLastError());
IndicatorRelease(atrHandle);
return 0.0;
}
double currentATR = atrArray[0];
IndicatorRelease(atrHandle);
if(currentATR <= 0.0)
{
Print("ATR value is zero or negative for ", symbol);
return 0.0;
}
// Calculate stop loss distance in price
double stopDistance = currentATR * atrMultiplier;
// Get symbol info for tick value and lot constraints
double tickValue = SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_VALUE);
double tickSize = SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_SIZE);
double lotStep = SymbolInfoDouble(symbol, SYMBOL_VOLUME_STEP);
double lotMin = SymbolInfoDouble(symbol, SYMBOL_VOLUME_MIN);
double lotMax = SymbolInfoDouble(symbol, SYMBOL_VOLUME_MAX);
if(tickValue <= 0.0 || tickSize <= 0.0)
{
Print("Invalid tick value or size for ", symbol);
return 0.0;
}
// Calculate number of ticks in stop distance
double ticksInStop = stopDistance / tickSize;
// Get account balance (use balance, not equity, to avoid whipsaw)
double accountBalance = AccountInfoDouble(ACCOUNT_BALANCE);
// Calculate raw lot size
double riskAmount = accountBalance * (riskPercent / 100.0);
double rawLots = riskAmount / (ticksInStop * tickValue);
// Round down to nearest valid lot step
double roundedLots = MathFloor(rawLots / lotStep) * lotStep;
// Clamp to broker limits
if(roundedLots < lotMin) roundedLots = lotMin;
if(roundedLots > lotMax) roundedLots = lotMax;
return roundedLots;
}
Key Implementation Details
Notice a few deliberate choices in the function above. First, I use ACCOUNT_BALANCE not ACCOUNT_EQUITY. Equity fluctuates wildly during open trades. If you have three positions running and one is underwater, equity-based sizing will shrink your next trade just when volatility might favor adding. Balance is more stable. Some traders prefer equity for tighter risk control — it's a design decision, not a right answer. I've personally switched to balance after watching equity-based sizing cause my EA to skip entries during drawdowns, missing the recovery trades.
Second, I round down with MathFloor. Always round down when sizing positions. Rounding up could put you over your intended risk, and in volatile conditions that extra 0.01 lot might be the difference between a manageable loss and a blown account. I've seen EAs round to nearest and then get margin-called because the lot step was 0.01 and rounding up added 5% extra risk. On a $5,000 account risking 2%, that extra 0.01 lot on a volatile pair could mean a $15 over-risk — not catastrophic alone, but compounded over 10 trades it adds up.
Third, I release the ATR handle immediately after copying the buffer. MQL5 handles are system resources. If your EA creates hundreds of indicator handles over its lifetime without releasing them, you'll hit handle limits (default is 64 per EA in MQL5) and the EA will stop working. Always release handles you don't need anymore. I've debugged EAs that mysteriously stopped calculating after 50 trades — the culprit was leaked indicator handles.
Why I Don't Use iATR() Directly in OnTick
Some developers prefer to create the ATR handle once in OnInit() and reuse it. That's valid, especially if you're calling the sizing function on every tick. But my standalone function approach has one advantage: it's self-contained. You can copy-paste it into any EA without worrying about initialization order or global variables. For high-frequency EAs that call sizing on every tick, you'd want to move the handle creation to OnInit() and pass the handle as a parameter. But for most swing and intraday EAs, creating and releasing the handle per call is fine — the overhead is negligible.
Here's a quick comparison of the two approaches:
| Approach | Pros | Cons | Best For |
|---|---|---|---|
| Self-contained function (create handle per call) | Portable, no global variables, easy to test | Slightly higher overhead, potential handle leak if not released | Swing trading, daily or H4 EAs, rapid prototyping |
| Handle created in OnInit(), reused in OnTick() | Faster per tick, no handle creation overhead | Requires global handle variable, more setup code | High-frequency scalping, M1 or tick-level EAs |
Integrating the Function Into Your EA
Here's how you'd call this from your OnTick() or trade entry logic. I'll show a complete example with error handling:
// Example: entry logic using ATR position sizing
double riskPercent = 1.0; // risk 1% of account per trade
int atrPeriod = 14;
double atrMultiplier = 2.0; // stop loss at 2x ATR
double lotSize = ATRPositionSizing(_Symbol, riskPercent, atrPeriod, atrMultiplier);
if(lotSize <= 0.0)
{
Print("Invalid lot size calculated, skipping trade");
return;
}
// Get current ATR for SL/TP calculation (reuse logic)
double atrArray[];
ArraySetAsSeries(atrArray, true);
int atrHandle = iATR(_Symbol, PERIOD_CURRENT, atrPeriod);
CopyBuffer(atrHandle, 0, 0, 1, atrArray);
double currentATR = atrArray[0];
IndicatorRelease(atrHandle);
// Your entry logic here, e.g.:
MqlTradeRequest request = {};
MqlTradeResult result = {};
request.action = TRADE_ACTION_DEAL;
request.symbol = _Symbol;
request.volume = lotSize;
request.type = ORDER_TYPE_BUY; // or sell
request.price = SymbolInfoDouble(_Symbol, SYMBOL_ASK);
request.sl = request.price - (atrMultiplier * currentATR);
request.tp = request.price + (atrMultiplier * 3 * currentATR); // 3:1 risk-reward
if(!OrderSend(request, result))
{
Print("Order send failed: ", result.retcode, " lot size: ", lotSize);
}
One thing I want to stress: never hardcode the ATR period or multiplier inside the function. Make them EA inputs so you can optimize them in the Strategy Tester. I usually expose these as input parameters at the top of my EA:
| Input | Type | Default | Description |
|---|---|---|---|
| RiskPercent | double | 1.0 |






