Why a Static Moving Average Period Hurts
You've seen it a hundred times. A 20-period SMA works beautifully during a quiet Tuesday on EURUSD, catching the trend with minimal lag. Then Thursday's NFP spike hits, volatility triples, and that same MA turns into a whip-saw machine. Flip the coin: set the period high enough to survive the spike, and you're smoothing out genuine trend moves during calm hours.
This isn't a flaw in the moving average itself. It's a mismatch between the filter and the market's current volatility regime. Most traders accept this trade-off, switching periods manually or ignoring it entirely. I've been guilty of both. But there's a better approach: let the moving average period adapt to volatility in real time.
In this post I'll walk through building a custom MQL5 indicator that does exactly that — a volatility-adjusted moving average that uses ATR to dynamically scale its lookback period. You'll get the full code, the reasoning behind each design choice, and honest notes on where this approach shines and where it falls apart.
Concept: Period as a Function of Volatility
The core idea is simple: when volatility expands (high ATR), increase the MA period to smooth out noise. When volatility contracts (low ATR), shorten the period to reduce lag and capture moves faster.
We need a mapping function. You can't just say "ATR is 50 pips, so period = 30." The relationship needs to be linear or logarithmic, bounded so the period never goes below a minimum (or you'll get a zero-length MA) or above a maximum (or it becomes unresponsive).
Here's the formula I settled on after testing a few variations:
DynamicPeriod = BasePeriod + (ATRValue / ATRMultiplier)Where BasePeriod is the minimum period (say 10), ATRValue is the current ATR reading, and ATRMultiplier is a scaling factor you tune. The result gets clamped between BasePeriod and a MaxPeriod.
This gives a linear relationship: double the ATR, roughly double the added period. In practice you'll want to normalize ATR relative to price (or use ATR as a percentage) to make the indicator cross-instrument. I'll show that in the code.
Why Normalization Matters
Raw ATR is an absolute value. On EURUSD, 50 pips is 0.0050. On Bitcoin, 50 pips is $50. If you feed raw ATR into the formula without normalization, the multiplier that works on EURUSD will be useless on BTCUSD. By dividing ATR by the current price, you get a ratio — essentially volatility as a percentage of price. This ratio tends to be stable across instruments, making your indicator portable.
MQL5 Custom Indicator Implementation
Let's build this step by step. Open MetaEditor (F4 in MetaTrader 5), create a new custom indicator: File → New → Expert Advisor → Custom Indicator, then pick "Custom Indicator". Name it VolatilityAdjustedMA. Set the drawing style to DRAW_LINE and the buffers to 1 for the main line, plus internal buffers for ATR and the computed period.
Input Parameters
Here's the input block. I've kept it lean — you can always add more later.
| Parameter | Type | Default | Description |
|---|---|---|---|
| MAPeriod | int | 20 | Base period for the moving average (minimum allowed). |
| MAType | ENUM_MA_METHOD | MODE_SMA | Type of MA (SMA, EMA, SMMA, LWMA). |
| ATRPeriod | int | 14 | Lookback period for ATR calculation. |
| ATRMultiplier | double | 0.002 | Scales normalized ATR to add to base period. Tune for your instrument. |
| MaxPeriod | int | 100 | Hard upper limit for the dynamic period. |
| PriceApplied | ENUM_APPLIED_PRICE | PRICE_CLOSE | Price series to feed into the MA (close, open, high, low, median, typical, weighted). |
OnInit and Buffer Setup
In OnInit() we set up three indicator buffers. One for the final MA line, one for the ATR values, and one for the computed period (you can hide the last two from the DataWindow if you prefer).
int OnInit()
{
IndicatorSetString(INDICATOR_SHORTNAME, "VolatilityAdjustedMA(" + IntegerToString(MAPeriod) + "," + IntegerToString(ATRPeriod) + ")");
SetIndexBuffer(0, maBuffer, INDICATOR_DATA);
SetIndexBuffer(1, atrBuffer, INDICATOR_CALCULATIONS);
SetIndexBuffer(2, periodBuffer, INDICATOR_CALCULATIONS);
ArraySetAsSeries(maBuffer, true);
ArraySetAsSeries(atrBuffer, true);
ArraySetAsSeries(periodBuffer, true);
PlotIndexSetInteger(0, PLOT_DRAW_TYPE, DRAW_LINE);
PlotIndexSetString(0, PLOT_LABEL, "VMA");
return(INIT_SUCCEEDED);
}A few notes on this setup. I'm using INDICATOR_CALCULATIONS for the ATR and period buffers — that keeps them out of the DataWindow and off the chart by default. If you want to see the period values for debugging, change them to INDICATOR_DATA and add a second plot. Also note the ArraySetAsSeries(true) calls. MQL5's default indexing is time-forward (0 = oldest bar), but for indicator calculations I prefer time-series indexing where index 0 is the current bar. This aligns with how you typically read price arrays in OnCalculate.
Core Logic in OnCalculate
Here's where the magic happens. On each tick or bar, we compute ATR, derive the dynamic period, and calculate the MA. I'm using a simple SMA for clarity, but you can swap in any MA type via iMA().
int OnCalculate(const int rates_total,
const int prev_calculated,
const datetime &time[],
const double &open[],
const double &high[],
const double &low[],
const double &close[],
const long &tick_volume[],
const long &volume[],
const int &spread[])
{
ArraySetAsSeries(high, true);
ArraySetAsSeries(low, true);
ArraySetAsSeries(close, true);
int limit = rates_total - prev_calculated;
if(limit > 1) limit = rates_total - 1;
for(int i = limit; i >= 0; i--)
{
if(i >= rates_total - ATRPeriod)
{
maBuffer[i] = EMPTY_VALUE;
continue;
}
// Calculate ATR manually for this bar
double trueRange = MathMax(high[i], close[i+1]) - MathMin(low[i], close[i+1]);
atrBuffer[i] = trueRange;
if(i < rates_total - ATRPeriod - 1)
{
double sum = 0;
for(int j = i; j < i + ATRPeriod; j++)
sum += atrBuffer[j];
atrBuffer[i] = sum / ATRPeriod;
}
// Normalize ATR relative to price to make it cross-instrument friendly
double normalizedATR = atrBuffer[i] / close[i];
// Compute dynamic period
int dynamicPeriod = (int)MathRound(MAPeriod + (normalizedATR / ATRMultiplier));
if(dynamicPeriod < MAPeriod) dynamicPeriod = MAPeriod;
if(dynamicPeriod > MaxPeriod) dynamicPeriod = MaxPeriod;
periodBuffer[i] = dynamicPeriod;
// Calculate SMA with dynamic period
if(i >= rates_total - dynamicPeriod)
{
maBuffer[i] = EMPTY_VALUE;
continue;
}
double sumClose = 0;
for(int j = i; j < i + dynamicPeriod; j++)
sumClose += close[j];
maBuffer[i] = sumClose / dynamicPeriod;
}
return(rates_total);
}Notice I normalize ATR by dividing by the close price. This makes the indicator work across different instruments without re-tuning the multiplier. On EURUSD daily, ATR might be 0.005 (50 pips). On BTCUSD, it might be 500. Dividing by price keeps the ratio comparable.
Edge Cases in the Code
There are a few edge cases the code handles that you might miss on first read. First, the initial bars where we don't have enough data for ATR or the MA — we set EMPTY_VALUE for those. Second, the ATR calculation uses the previous bar's close for the true range formula. This means the very first bar of the series (index rates_total - 1) can't compute a proper true range because there's no close[i+1]. The code skips that bar. Third, the dynamic period can change every bar, so the number of bars with EMPTY_VALUE varies. The if(i >= rates_total - dynamicPeriod) check ensures we only plot the MA when we have enough data.
One thing this code doesn't do well: handle gaps. If there's a weekend gap or a data gap, the true range calculation using close[i+1] might produce an inflated value. For most forex and index data this is fine, but on crypto data with 24/7 markets you might see odd spikes at rollover times. You could add a check for gap size and cap the true range, but I've kept it simple here.
Pros, Cons, and Risks
Let's be honest: this isn't a silver bullet. I've seen traders slap adaptive logic onto everything and expect miracles. It doesn't work that way.
Where It Shines
- Reduced whipsaws during volatility spikes. When news hits and price jolts, the period lengthens automatically. You don't get false crossovers from a 20-period EMA reacting to a single candle.
- Faster response in quiet markets. When volatility drops, the period shortens. The MA hugs price closer, giving earlier signals in trends.
- Cross-instrument portability. With normalized ATR, the same parameter set works on FX, indices, and crypto with minimal tweaking. I've run this on EURUSD H4 and BTCUSD H4 with the same ATRMultiplier of 0.002 and got usable results.
- No repainting. Unlike some adaptive indicators that






