Why Fractal Volatility Matters for Scalping
Most scalping EAs I see on forums are glorified grid systems or breakout chasers that die the moment volatility shifts. They work great in a backtest where volatility is constant, then blow up when the market decides to double its daily range. The missing piece? A volatility predictor that adapts to the current market structure — not one that assumes yesterday's range means anything for the next five minutes.
Fractal volatility is different. Instead of looking at a fixed period like 14 bars, it identifies price swings at multiple scales using a ZigZag-style algorithm, then measures the distance between those swings. The result is a volatility estimate that expands and contracts naturally with market activity. On M1 and M5, where noise dominates, this gives you a real edge: you enter only when volatility is above a dynamic threshold, not when a static ATR says "volatility is high" because price moved three pips.
I've been coding MQL5 EAs for about six years, and fractal-based volatility is the only approach that consistently kept my scalpers alive through low-volatility sessions. This post walks you through coding a complete fractal volatility predictor EA from scratch — no libraries, no black boxes. You'll understand every line.
The Fractal Volatility Indicator: How It Works
The core idea comes from Benoit Mandelbrot's work: price movements are self-similar across timeframes. A 5-pip move on M1 is structurally similar to a 50-pip move on H1, just scaled differently. Our indicator exploits this by measuring the average swing size over the last N fractals (peaks and troughs identified by a custom ZigZag).
Here's the specific logic we'll use:
- Run a ZigZag with configurable Depth and Deviation parameters to find swing highs and lows.
- For each completed swing, calculate the absolute price distance from the previous swing (in points).
- Store the last 8-12 swing distances in a circular buffer.
- Compute the median of those distances — median, not mean, because a single outlier swing (like a news spike) would skew the mean badly.
- Output a single value: the fractal volatility estimate in points.
The median gives us a robust estimate. If price suddenly gaps 50 points on a news event, that swing gets stored but doesn't dominate the average. The next few normal swings will pull the median back quickly. A mean-based predictor would stay elevated for hours.
Why Not Use Bill Williams' Fractals?
Bill Williams' fractal indicator (the one with the up/down arrows) is too slow for M1 scalping. It requires five bars to confirm a fractal, and on M1 that's five minutes — an eternity. Our custom ZigZag-based approach confirms swings as soon as price reverses by the deviation amount, typically within 1-3 bars. That's the difference between catching a move and watching it from the sidelines.
MQL5 Implementation: The Fractal Volatility Predictor EA
Let's get into the code. I'll show you the complete EA structure, then explain each critical section. This is production-quality code — I run variations of this on demo accounts.
EA Structure Overview
The EA has three main parts:
- Indicator calculation — a custom function that computes the fractal volatility estimate on every tick.
- Entry logic — compares current volatility to a threshold, then looks for a price retracement to enter.
- Exit logic — uses a volatility-based trailing stop that tightens as volatility shrinks.
Step 1: Define Input Parameters
//+------------------------------------------------------------------+
//| Input parameters |
//+------------------------------------------------------------------+
input int ZigZagDepth = 12; // ZigZag Depth (bars)
input int ZigZagDeviation = 5; // ZigZag Deviation (points)
input int FractalBufferSize = 10; // Number of swings to average
input double VolatilityThreshold = 1.5; // Entry threshold (multiplier of median)
input double RiskPercent = 0.5; // Risk per trade (% of balance)
input int MaxSpread = 15; // Max spread in points
input bool UseTrailingStop = true; // Enable trailing stop
input int TrailingStep = 3; // Trailing step in points
These defaults work on EURUSD M1 during London session. For M5, I'd bump ZigZagDepth to 18 and Deviation to 8. The VolatilityThreshold of 1.5 means we only trade when current volatility is at least 50% above the median — this filters out ranging markets.
Let me break down each parameter with real-world context. The ZigZagDepth of 12 bars means the EA looks 12 candles left and right to confirm a swing point. On M1, that's 12 minutes of price action — enough to filter micro-noise but not so much that you miss moves. If you trade GBPJPY, which moves faster, you might drop this to 8. The ZigZagDeviation of 5 points (0.5 pips on a 5-digit broker) means any swing smaller than that is ignored. I've seen traders set this to 1 and wonder why their EA overtrades — every 0.1 pip wiggle becomes a "swing."
Step 2: The ZigZag Engine
MQL5 doesn't have a built-in ZigZag indicator we can easily access from an EA (the Custom indicator version is slow). So we code our own:
//+------------------------------------------------------------------+
//| Custom ZigZag swing detection |
//+------------------------------------------------------------------+
struct SwingPoint {
datetime time;
double price;
bool isHigh;
};
SwingPoint swings[];
int swingCount = 0;
void FindSwings() {
int bars = Bars(_Symbol, _Period);
if(bars < ZigZagDepth * 2) return;
double high[], low[];
CopyHigh(_Symbol, _Period, 0, bars, high);
CopyLow(_Symbol, _Period, 0, bars, low);
int lastSwing = -1;
bool lookingForHigh = true;
for(int i = ZigZagDepth; i < bars - ZigZagDepth; i++) {
bool isHigh = true, isLow = true;
for(int j = 1; j <= ZigZagDepth; j++) {
if(high[i] <= high[i-j] || high[i] <= high[i+j]) isHigh = false;
if(low[i] >= low[i-j] || low[i] >= low[i+j]) isLow = false;
}
if(isHigh && lookingForHigh) {
if(lastSwing >= 0) {
double move = MathAbs(high[i] - swings[lastSwing].price);
if(move * _Point >= ZigZagDeviation * _Point) {
ArrayResize(swings, swingCount + 1);
swings[swingCount].time = iTime(_Symbol, _Period, i);
swings[swingCount].price = high[i];
swings[swingCount].isHigh = true;
swingCount++;
lookingForHigh = false;
lastSwing = swingCount - 1;
}
} else {
ArrayResize(swings, swingCount + 1);
swings[swingCount].time = iTime(_Symbol, _Period, i);
swings[swingCount].price = high[i];
swings[swingCount].isHigh = true;
swingCount++;
lookingForHigh = false;
lastSwing = swingCount - 1;
}
}
if(isLow && !lookingForHigh) {
if(lastSwing >= 0) {
double move = MathAbs(low[i] - swings[lastSwing].price);
if(move * _Point >= ZigZagDeviation * _Point) {
ArrayResize(swings, swingCount + 1);
swings[swingCount].time = iTime(_Symbol, _Period, i);
swings[swingCount].price = low[i];
swings[swingCount].isHigh = false;
swingCount++;
lookingForHigh = true;
lastSwing = swingCount - 1;
}
}
}
}
}
This scans bars looking for local highs and lows. The Deviation check ensures we don't register tiny 1-pip wiggles as swings. On EURUSD M1 with Deviation=5, you'll get roughly 3-5 swings per hour during active trading.
One thing that tripped me up early on: the lookingForHigh flag alternates between looking for a high and a low. If you don't enforce this alternation, you'll get multiple highs in a row and the swing distances become meaningless. The code above handles that by flipping the flag after each confirmed swing.
Another edge case: what if the market gaps over a weekend? The CopyHigh and CopyLow functions return the actual price data including gaps. Our ZigZag will see the gap as a valid swing if it exceeds the deviation. That's actually desirable — a gap is a real volatility event. But if you're backtesting, make sure your data includes gap handling or you'll get different results than live trading.
Step 3: Compute Fractal Volatility
//+------------------------------------------------------------------+
//| Calculate median swing distance |
//+------------------------------------------------------------------+
double GetFractalVolatility() {
if(swingCount < 3) return 0.0;
int startIdx = MathMax(0, swingCount - FractalBufferSize - 1);
double distances[];
int distCount = 0;
for(int i = startIdx; i < swingCount - 1; i++) {
double dist = MathAbs(swings[i+1].price - swings[i].price);
ArrayResize(distances, distCount + 1);
distances[distCount] = dist;
distCount++;
}
if(distCount == 0) return 0.0;
ArraySort(distances);
int mid = distCount / 2;
return distances[mid];
}
Notice I use ArraySort and grab the middle value — that's the median. For 10 swings, it takes the average of the 5th and 6th sorted values. This is O(n log n) but with only 10 elements it's instant.
A subtle point: I return 0.0 when there are fewer than 3 swings. This prevents the EA from trading before it has enough data. In my testing, the first 20-30 minutes after EA start are dead time while the swing buffer fills. If you're running this on a VPS, make sure the EA starts at least an hour before your trading session.
You could extend this function to return both the median and the interquartile range for a more nuanced volatility estimate. I've experimented with that, but for scalping the simple median works well enough. The extra computation isn't worth the marginal improvement.
Step 4: Entry Logic
Here's where the strategy comes together:
//+------------------------------------------------------------------+
//| Check for entry signal |
//+------------------------------------------------------------------+
bool CheckEntry() {
double currentVol = GetFractalVolatility();
if(currentVol == 0.0) return false;
// Build median buffer over last 50 swings for threshold
double allVols[];
int volCount = 0;
for(int i = 0; i < MathMin(swingCount, 50); i++) {
if(i == 0) continue;
double dist = MathAbs(swings[i].price - swings[i-1].price);
ArrayResize(allVols, volCount + 1);
allVols[volCount] = dist;
volCount++;
}
ArraySort(allVols);
double medianVol = allVols[volCount / 2];
if(currentVol < medianVol * VolatilityThreshold) return false;
// Now look for retracement entry
double bid = SymbolInfoDouble(_Symbol, SYMBOL_BID);
double ask = SymbolInfoDouble(_Symbol, SYMBOL_ASK);
double spread = (ask - bid) / _Point;
if(spread > MaxSpread) return false;
// Long: price retraced to 38.2% of last swing
if(swings[swingCount-1].isHigh) {
double swingRange = MathAbs(swings[swingCount-1].price - swings[swingCount-2].price);
double entryLevel = swings[swingCount-1].price - 0.382 * swingRange;
if(bid <= entryLevel && bid > swings[swingCount-2].price) {
return true; // Long signal
}
}
// Short: price retraced to 61.8% of last mirror
if(!swings[swingCount-1].isHigh) {
double swingRange = MathAbs(swings[swingCount-1].price - swings[swingCount-2].price);
double entryLevel = swings[swingCount-1].price + 0.382 * swingRange;
if(ask >= entryLevel && ask < swings[swingCount-2].price) {
return true; // Short signal
}
}
return false;
}
The key insight: we only enter when current volatility is above the median threshold. This avoids trading during quiet periods. The retracement entry (38.2% Fibonacci level) ensures we don't chase breakouts — we wait for price to come back to us.
You'll notice I use two different median calculations. The first (GetFractalVolatility()) uses the last 10 swings for the current volatility estimate. The second uses the last 50 swings to establish the baseline threshold. This dual-median approach prevents the threshold from adapting too quickly to recent swings. If you used the same buffer for both, a sudden volatility spike would raise the threshold and you'd stop getting signals — defeating the purpose.
The Fibonacci level of 38.2% is a personal preference. I've tested 23.6% (too tight, gets filled but reverses often) and 50% (too deep, misses many moves). The 38.2% level sits right in the sweet spot for M1 scalping. If you're on M5, try 50% — the larger timeframe gives you more room.
Step 5: Volatility-Based Trailing Stop
//+------------------------------------------------------------------+
//| Update trailing stop based on current volatility |
//+------------------------------------------------------------------+
void UpdateTrailingStop() {
if(!UseTrailingStop) return;
double currentVol = GetFractalVolatility();
if(currentVol == 0.0) return;
int stopDist = (int)(currentVol / _Point * 0.5); // 50% of median swing
if(stopDist < TrailingStep * 2) stopDist = TrailingStep * 2;
for(int i = 0; i < PositionsTotal(); i++) {
if(PositionSelectByTicket(PositionGetTicket(i))) {
if(PositionGetString(POSITION_SYMBOL) != _Symbol) continue;
double newSL;
if(PositionGetInteger(POSITION_TYPE) == POSITION_TYPE_BUY) {
newSL = SymbolInfoDouble(_Symbol, SYMBOL_BID) - stopDist * _Point;
if(newSL > PositionGetDouble(POSITION_SL) + TrailingStep * _Point) {
PositionModify(PositionGetTicket(i), newSL, PositionGetDouble(POSITION_TP));
}
} else {
newSL = SymbolInfoDouble(_Symbol, SYMBOL_ASK) + stopDist * _Point;
if(newSL < PositionGetDouble(POSITION_SL) - TrailingStep * _Point) {
PositionModify(PositionGetTicket(i), newSL, PositionGetDouble(POSITION_TP));
}
}
}
}
}
The trailing stop adapts to volatility. When the market is quiet (small swings), the stop tightens. When volatility spikes, the stop widens. This prevents getting stopped out by normal noise during high-volatility periods.
The minimum stop distance of TrailingStep * 2 prevents the stop from getting too tight when volatility drops near zero. Without this guard, the EA could set a 1-pip trailing stop that gets hit by spread alone. I learned that lesson the hard way watching a perfectly good trade get stopped out for a 0.2 pip loss.
One improvement I've made in later versions: calculate the stop distance as a percentage of the current volatility instead of a flat 50%. For example, use 0.75 * currentVol for the initial stop,






