You can see it in half a second. Price poked above the high that had been holding all morning, everything short of it got taken out, and by the time the candle closed the whole move had been handed back. You did not reason your way there. You just knew that was a raid, not a breakout.
Your expert advisor knows nothing of the sort. It sees a number, then another number. The gap between "I can see that was a sweep" and "the terminal can see it too" is where most ICT builds stall: you have to write that instant read as an arithmetic test that returns true on the raid and false on the hundred ordinary wicks that look almost identical.
This is that piece of work. Levels first, then the breach test, then the filter that separates a raid from a real break, then both directions assembled into one MQL5 function you can drop into an EA - and the single bug that turns all of it into a live-trading liability if you get it wrong.
Key Takeaways
A sweep is a breach the same bar undoes: the wick trades beyond the level, the body closes back inside. If the close is beyond the level, you have coded a break of structure instead.
The breach test alone fires on ordinary wicks. The rejection filter, measuring the wick that gave the level back against the bar's body and scaling the run by ATR, is what makes it tradeable.
Evaluate bar 1 only, once per bar. Reading the forming bar produces a signal that appears and disappears, and a backtest that will not reproduce live.
Return the level, the sweep extreme and the side, not a trade. Session gating, structure confirmation and position sizing are separate modules that consume that fact.
Table of Contents (23 min read)Contents
What Counts as a Liquidity Sweep an EA Can Actually Trade?
Start with what the level is, because that decides what your code measures.
A prior swing high is not "resistance" to an algorithm. It is an address. Traders short from below park protective stop-loss orders just above it; traders waiting for a breakout park buy stops in the same place. Both are resting buy orders sitting in one narrow band of price, which is what liquidity means in the literal sense your code cares about: orders available to be filled against. That band is buy-side liquidity - BSL - and the mirror band of resting sell orders under a swing low is sell-side liquidity, SSL. A sweep is what filling against that pool looks like on a chart: price is driven into the cluster, the resting orders execute, and price leaves without them.
That hands you a mechanical definition, and it is short. A sweep is a breach the same bar undoes. The wick trades beyond the level. The body closes back inside it.
The word doing all the work there is close. If price closes beyond the level and the next bars keep going, nothing was swept - that is a break of structure, and a detector that fires on it is a breakout detector you built by accident.
The codeable distinction
Liquidity sweep vs breakout: the close decides
Liquidity sweep
The wick trades beyond the level and the body closes back inside it
The level is given back inside the same bar, usually fast
The wick beyond the level is large relative to the body
Reads as resting orders being filled, then price leaving
In code: high > level AND close < level
VS
Breakout / break of structure
The body closes beyond the level and following bars continue away from it
The level flips role: old resistance starts acting as support
The body dominates the bar; the wick beyond is small or absent
In code: close > level, which is a different event entirely
Same wick, opposite meaning. The bar's close is the only field that separates the two.
Everything below builds one function out of that rule. Five checks, in a fixed order, run once per bar.
Detection pipeline
What the detector does on every closed bar
1
Wait for the bar to close
The forming bar's high, low and close can all still change. Every check below runs once, on the bar that just finished.
2
Rebuild the liquidity map
Scan back for confirmed swing highs and lows, and pair any that sit within a volatility-scaled tolerance of each other.
3
Test the breach
Did the wick trade beyond one of those levels while the body closed back inside it? If not, stop here - most bars stop here.
4
Measure the rejection
Weigh the wick that gave the level back against the bar's body. A weak wick is ordinary noise, not a raid.
5
Return the fact, not the trade
Emit the level, the sweep extreme and the side. Session gating, confirmation and position sizing are separate modules.
The order matters: the cheapest test that can reject a bar runs first, so most bars cost you two comparisons.
Coding the Levels: Swing Highs, Swing Lows, and Equal Highs/Lows
A breach test needs something to breach. Before your EA can ask "was this level run?", it needs a maintained list of levels worth running - and that list is where most of the judgment in this whole block lives.
Two shapes are worth coding, and only two.
Confirmed swing points are the base case. A bar is a swing high when its high tops the highs of depth bars on both sides of it. The cost of that definition is a confirmation lag: a bar cannot be called a swing high until depth more bars have printed to its right and failed to exceed it. That lag is not a flaw to engineer away. It is the price of the level being real, and trying to remove it is the first way people accidentally build a level that moves after the fact.
Equal highs and equal lows are the higher-quality target. When price attacks a level twice and fails twice, the second failure stacks a second set of stops in the same place. Two swing highs count as equal when they sit within a tolerance - and that tolerance must scale with volatility, not be a fixed number of points. A five-point tolerance that behaves sensibly on EUR/USD is meaningless on gold. Express it as a fraction of ATR and one constant works across your whole symbol list.
Step 1 of the pipeline
mql5liquidity_levels.mqh
//--- Bar `shift` is a confirmed swing high when it tops `depth` bars on BOTH sides.
bool IsSwingHigh(const int shift, const int depth)
{
double pivot = iHigh(_Symbol, PERIOD_CURRENT, shift);
for(int k = 1; k <= depth; k++)
{
if(iHigh(_Symbol, PERIOD_CURRENT, shift + k) >= pivot) return(false);
if(iHigh(_Symbol, PERIOD_CURRENT, shift - k) >= pivot) return(false);
}
return(true);
}
//--- Collect confirmed swing highs, newest first. Start at depth+1: a swing needs
//--- `depth` bars to its right before it is a swing at all, so bar 0 never counts.
int CollectSwingHighs(const int depth, const int lookback, double &levels[])
{
ArrayResize(levels, 0);
for(int shift = depth + 1; shift <= lookback; shift++)
{
if(!IsSwingHigh(shift, depth)) continue;
int n = ArraySize(levels);
ArrayResize(levels, n + 1);
levels[n] = iHigh(_Symbol, PERIOD_CURRENT, shift);
}
return(ArraySize(levels));
}
//--- Two levels are "equal" within a tolerance that scales with volatility, so one
//--- constant behaves the same way on EUR/USD and on gold.
bool AreEqualLevels(const double a, const double b, const double atr, const double tolAtr)
{
return(MathAbs(a - b) <= atr * tolAtr);
}
//--- A level with an equal partner holds more resting orders than a lone swing.
bool HasEqualPair(const double &levels[], const double level,
const double atr, const double tolAtr)
{
int hits = 0;
for(int i = 0; i < ArraySize(levels); i++)
if(AreEqualLevels(levels[i], level, atr, tolAtr)) hits++;
return(hits > 1);
}
//--- For a buy-side sweep the meaningful target is the HIGHEST level the wick cleared.
double HighestLevelBelow(const double &levels[], const double price)
{
double best = 0.0;
for(int i = 0; i < ArraySize(levels); i++)
if(levels[i] < price && levels[i] > best) best = levels[i];
return(best);
}
//--- CollectSwingLows() and LowestLevelAbove() are these two with every comparison
//--- flipped. Write them once and mirror them exactly - no clever shared version.
Level detection in one header: find the swings, decide which ones are paired, and pick the one the wick actually cleared.
Two details in there earn their keep. The loop starts at depth + 1 rather than 1, which structurally guarantees the level list can never include the forming bar - the swing definition needs bars on the newer side, and the newest bar it can reach is bar 1. And HighestLevelBelow picks the highest level the wick cleared rather than the nearest one, because when a wick runs three stacked highs, the deepest one is the pool that was actually emptied.
One more field belongs on each level in a production build: a swept flag. A pool that has been taken is not a live target any more, and a detector that keeps re-firing on the same high will hand you the same signal every bar until price finally leaves.
The Breach Test: Wick Beyond the Level, Close Back Inside
The rule is now two comparisons per direction, and the mirroring is exact.
Step 2 of the pipeline
mql5breach_test.mqh
//--- Buy-side breach: the wick trades above the level, the body closes back below it.
bool IsBuySideBreach(const int shift, const double level, const double minRun)
{
double high = iHigh(_Symbol, PERIOD_CURRENT, shift);
double close = iClose(_Symbol, PERIOD_CURRENT, shift);
if(high <= level + minRun) return(false); // grazed it, never ran it
if(close >= level) return(false); // closed beyond it: a break, not a sweep
return(true);
}
//--- Sell-side breach: the wick trades below the level, the body closes back above it.
bool IsSellSideBreach(const int shift, const double level, const double minRun)
{
double low = iLow(_Symbol, PERIOD_CURRENT, shift);
double close = iClose(_Symbol, PERIOD_CURRENT, shift);
if(low >= level - minRun) return(false);
if(close <= level) return(false);
return(true);
}
Four lines of arithmetic carry the whole definition: the wick must clear the level by a real distance, and the close must land back inside.
The minRun argument is the part people leave out and then regret. Written as a bare high > level, the test is true when the wick clears by a tenth of a pip - which happens constantly and means nothing, because a level grazed is not a level run. Passing minRun as a fraction of ATR requires the wick to have genuinely travelled into the pool, and it keeps the same code honest on a symbol that moves in whole points as on one that moves in fractions of a pip.
Here is the pattern the two functions are looking for, on a level that took two attempts to build.
Worked example
A buy-side pool built twice, then taken in one barEUR/USD15m
The sweep bar ran 13 pips past the level and closed 15 pips back under it. To the detector, only the last two numbers matter.
Sweep or Breakout? Adding a Rejection Filter
Run the breach test alone across a month of 15-minute bars and you will find it generous. It says nothing more than "the level was touched and given back" - and that describes an enormous amount of ordinary noise: a thin hour, a spread flare, a bar that drifted two pips past a level on its way to nowhere. Without a strength test you have built a wick counter, and every false signal it emits costs you a position.
The filter that fixes it has three parts, and they are cheap in the order listed.
Penetration depth. Already handled by minRun: the wick has to travel a meaningful distance past the level, expressed in ATR rather than points.
Rejection ratio. Measure the wick that gave the level back - high - max(open, close) for a buy-side sweep - against the bar's body. When the wick beyond the body is larger than the body itself, the bar spent most of its life somewhere it could not stay.
Close position in range. A buy-side sweep should close in the lower part of its own range. A bar that ran the highs and closed mid-range is undecided, whatever its wick looks like.
The sweep bar in the chart above scores cleanly on all three: it ran 13 pips past the level, then gave back 19 pips of upper wick against a 9-pip body, and closed in the bottom tenth of a 30-pip range. That is what you are trying to require. Move the numbers around and watch how quickly a normal-looking bar stops qualifying.
Tune it yourself
Rejection strength of a candidate sweep bar
Measure the bar in pips or points, whichever your symbol quotes in. Defaults are the sweep bar from the chart above.
Wick beyond the body (rejection)
pips
Body (open to close)
pips
Wick on the other side
pips
Your minimum wick-to-body ratio
Wick-to-body ratio
—
Bar range
—
Rejection share of the range
—
Headroom over your threshold
—
Drag the threshold until the bars you would actually trade pass and the ones you would ignore do not. That number is your InpMinWickRatio.
One warning about that threshold. It is the easiest input in the whole EA to curve-fit: nudge it by 0.05 and a losing test turns into a winning one, and you have learned nothing except which noise your sample contained. Tune it on a coarse grid - 1.0, 1.5, 2.0 - and take a value that sits inside a broad plateau of acceptable results rather than on a lucky spike.
A Working MQL5 Function: Detecting Buy-Side and Sell-Side Sweeps
Assembled, the whole thing is one function that takes a bar index and returns a small struct. Both directions are present, mirrored line for line.
The assembled detector
mql5sweep_detector.mqh
struct SweepSignal
{
bool found; // did a sweep complete on this bar?
bool buySide; // true = buy-side pool taken, false = sell-side
double level; // the liquidity level that was run
double extreme; // the sweep high or low - your natural stop reference
datetime barTime; // open time of the bar that did it
};
//--- Evaluate ONE fully closed bar (shift >= 1) against the current liquidity map.
SweepSignal DetectSweep(const int shift, const double atr)
{
SweepSignal s;
s.found = false; s.buySide = false; s.level = 0.0; s.extreme = 0.0;
s.barTime = iTime(_Symbol, PERIOD_CURRENT, shift);
double open = iOpen(_Symbol, PERIOD_CURRENT, shift);
double high = iHigh(_Symbol, PERIOD_CURRENT, shift);
double low = iLow(_Symbol, PERIOD_CURRENT, shift);
double close = iClose(_Symbol, PERIOD_CURRENT, shift);
double body = MathMax(MathAbs(close - open), _Point);
double minRun = atr * InpMinRunAtr;
//--- Buy-side: run the highs, close back under them.
double bsl[];
if(CollectSwingHighs(InpDepth, InpLookback, bsl) > 0)
{
double lvl = HighestLevelBelow(bsl, high);
if(lvl > 0.0 && IsBuySideBreach(shift, lvl, minRun)
&& (!InpRequirePair || HasEqualPair(bsl, lvl, atr, InpTolAtr)))
{
double rejection = high - MathMax(open, close);
if(rejection / body >= InpMinWickRatio)
{
s.found = true; s.buySide = true; s.level = lvl; s.extreme = high;
return(s);
}
}
}
//--- Sell-side: the same three tests with every comparison mirrored.
double ssl[];
if(CollectSwingLows(InpDepth, InpLookback, ssl) > 0)
{
double lvl = LowestLevelAbove(ssl, low);
if(lvl > 0.0 && IsSellSideBreach(shift, lvl, minRun)
&& (!InpRequirePair || HasEqualPair(ssl, lvl, atr, InpTolAtr)))
{
double rejection = MathMin(open, close) - low;
if(rejection / body >= InpMinWickRatio)
{
s.found = true; s.buySide = false; s.level = lvl; s.extreme = low;
}
}
}
return(s);
}
Levels, breach, pairing, rejection - in that order, per direction. The function reports what happened and stops there.
Three design choices in there are worth copying rather than reworking.
It returns a struct, not a trade. found is a fact about a bar; whether that fact deserves an order depends on filters this function has no business knowing about. Keep detection pure and you can test it by printing signals to the journal, with no trading code involved at all.
It carries the extreme, not just the level. The sweep's high (or low) is the price that proved wrong the moment price came back - which makes it the structurally honest place to reference a stop, rather than a fixed pip distance chosen for convenience.
It returns on the first match. A bar cannot sweep buy-side and sell-side liquidity at once in any way you want to trade; if your data ever produces both, you are looking at a bar wide enough that the whole map needs rebuilding on a higher timeframe.
Avoid the Repainting Trap: Evaluate Closed Bars Only
Everything above is correct and still capable of losing you money, for one reason: which bar you point it at.
Suppose the detector runs on every tick against the forming bar. Mid-bar, the high is 1.0879 and price is trading at 1.0851 - the breach test passes, the rejection ratio passes, and your EA fires. Twenty minutes later that same bar closes at 1.0871, above the level. There was never a sweep. There was a bar in progress that happened, for a while, to look like one.
That is repainting in its purest form, and it has a distinctive tell: the backtest looks excellent and the live account does not resemble it.
What makes it so durable is that the standard test hides it. In the Strategy Tester, "Open prices only" evaluates each bar once, at completion, so the bug is invisible - the tester quietly hands you the very discipline your live code lacks. Run "Every tick based on real ticks" instead and the two results separate immediately.
There are two sources of it, and you have to close both.
The evaluated bar. Only ever read shift = 1, the bar that has finished, and evaluate once when a new bar opens rather than on every tick.
The level itself. A swing high needs bars to its right before it qualifies. Building levels from bar 0, or shortening the confirmation window to make signals appear sooner, produces levels that appear and disappear - and a level that moves makes every breach test downstream of it meaningless.
The one bug that matters
mql5sweep_ea.mq5
input int InpDepth = 3; // bars either side that define a swing
input int InpLookback = 120; // how far back the liquidity map reaches
input double InpMinRunAtr = 0.15; // minimum run past the level, in ATR
input double InpMinWickRatio = 1.2; // rejection wick / body
input double InpTolAtr = 0.10; // equal-level tolerance, in ATR
input bool InpRequirePair = false; // only accept levels with an equal partner
datetime g_lastBar = 0;
datetime g_lastHandled = 0;
void OnTick()
{
//--- Bar 0 is still forming: its high, low and close can all still change.
//--- Act once per bar, and only ever read bar 1 - the one that is finished.
datetime current = iTime(_Symbol, PERIOD_CURRENT, 0);
if(current == g_lastBar) return;
g_lastBar = current;
double atr = CurrentAtr(14); // thin wrapper over iATR + CopyBuffer
if(atr <= 0.0) return;
SweepSignal s = DetectSweep(1, atr);
if(!s.found) return;
//--- One signal per bar, even if the EA is reloaded or the chart changes.
if(s.barTime == g_lastHandled) return;
g_lastHandled = s.barTime;
//--- Hand the fact upward. What to do about it belongs to another module.
OnLiquiditySwept(s);
}
The new-bar gate is six lines and it is the difference between a detector you can trust and one that changes its mind.
A condition that is true halfway through a bar can be false at its close, which is why the detector only ever reads the bar behind it.
False Sweeps That Pass Every Filter You Just Wrote
There is a last category the arithmetic cannot reach: bars that satisfy every test and still should not be traded, because the context around the level disqualifies it. These are the ones that survive a clean backtest and bleed in live trading, so it is worth encoding them as explicit refusals rather than hoping the ratio filter catches them.
Context the arithmetic misses
Before the detector is allowed to size a position
0 / 7
Mark every level swept the moment it fires, and stop treating it as a live pool - a high that has already been run is not holding what it held an hour ago.
Require the run past the level to exceed a fraction of current ATR, so a level grazed by half a pip is never recorded as swept.
Skip the opening bars of a session and any bar carrying a scheduled high-impact release, where a long wick is normal behaviour rather than a raid.
Check whether the level is a round number that price wicks through routinely, and demand a stronger rejection there than on a structural swing.
Confirm the level came from a timeframe your EA actually maintains - a 15-minute swing high is not the pool a 4-hour sweep was aiming at.
Remember MT5 bars are built from bid prices: on a wide-spread symbol the ask can run a level your bar data never shows as breached.
Log every rejected candidate with the reason it failed, so you can see which filter is doing the work before you trust any of them.
★
Checklist complete — you’re cleared to proceed.
Each line is a real refusal, not a preference - and each one is cheaper to code than to discover in a live account.
The stale-level trap is the one that catches almost everyone. A pool is a one-time resource; once it has been emptied, the same high is just a price. If the detector has no memory, it will hand you the identical signal on the next bar, and the one after that, until price finally walks away - and each of those repeats looks like a fresh setup in the journal.
The timeframe trap is subtler and more expensive. Levels and evaluation do not have to live on the same chart period, and in most ICT builds they should not: the pool is a higher-timeframe object and the raid on it is a lower-timeframe event. Deciding that split deliberately is what multi-timeframe confirmation means in code, and leaving it accidental is why a detector that looked flawless on H4 produces noise on M5.
Where Sweep Detection Plugs Into the Rest of Your EA
DetectSweep() answers one question and refuses every other. That is the point - but it means three neighbouring modules have to exist around it before it can trade.
Upstream sits a gate. Sweeps are not equally meaningful at every hour, and most ICT builds only act on ones that land in specific windows. Which window, and how you handle broker server time versus your own clock, is its own build problem - session timing for when sweeps are traded belongs in a trading session filter that runs before the detector is ever called, not in a condition bolted onto the end of it.
Downstream sits confirmation. A completed sweep tells you a pool was emptied. It does not tell you the market has turned - price can take the highs and continue higher a dozen bars later. Most builds pair the sweep with a market structure shift on a lower timeframe: the sweep says where, and the shift that follows says now. That shift has its own detection logic and its own repainting hazards, and it is a separate module rather than an extra clause inside this one. Treat the sweep as the arming step and the shift as the trigger, and each piece stays testable on its own.
Alongside sits risk. Because the struct carries the sweep extreme, your stop reference is already available at signal time, which is what lets position size be derived from structure instead of from a fixed pip count. That is also the point where the confirmation candle you decide to require starts affecting how far away that stop sits.
Wired that way, sweep detection is one replaceable component in the full ICT-to-EA automation workflow - a function you can rewrite, retune, or swap for a different definition of "level" without touching anything around it.
Compliance
Build It in This Order
The read you do in half a second is really four separate judgments stacked on top of each other, and they only survive translation into code if you write them separately.
Start with the level map and nothing else: draw the swings your code finds and check them against the ones your eye finds. Then add the breach test and let it print signals for a week without placing a single order - it will be noisy, and that noise is the data you tune the rejection filter against. Add the filter. Only then wire in the session gate, the confirmation step, and sizing.
Do it in that order and each stage tells you whether the previous one was right. Do it all at once and, when the results disappoint, you will have no way of knowing which of the four judgments your bot got wrong.
FAQ
Which timeframe should the sweep detector run on?
Run the level map and the breach test on different periods rather than forcing one choice. Liquidity pools that matter are usually higher-timeframe objects - session highs and lows, daily extremes, H4 swings - while the raid on them is a lower-timeframe event that plays out inside one or two bars. A common split is levels from H4 or D1 and evaluation on M15, but the honest answer is that the pair should match the levels you already trade by eye.
Does waiting for the bar to close cost you the entry?
Sometimes, and that is the correct trade-off. Acting mid-bar means acting on a condition that can un-happen, which is not an earlier entry but a different and worse signal. If the delay genuinely breaks the setup, the fix is to evaluate on a faster period - a closed M5 bar arrives sooner than a closed M15 one - not to read the bar that has not finished.
How many liquidity levels should the EA keep at once?
Few enough that each one means something. A lookback of a hundred-odd bars with a swing depth of three or four typically leaves a handful of live levels per symbol, which is roughly what you would mark manually. If the list runs to dozens, the swing definition is too loose, and the detector will find a "sweep" against something almost every bar.
Do you need volume to confirm a liquidity sweep?
No, and on most MT5 forex symbols you could not do it honestly anyway - the volume figure is tick count from your broker's feed, not traded contracts, and it varies between brokers on the same pair. The bar's own geometry carries the same information more reliably: a long rejection wick against a small body already tells you price could not hold where it went.
How do you tune the rejection ratio without curve-fitting it?
Test a coarse grid rather than a fine one, and choose from the shape of the results instead of the peak. If ratios of 1.0, 1.5 and 2.0 all produce broadly similar behaviour and 1.6 produces a spike, the spike is a property of your sample, not of the market. Pick a value inside the flat region, then confirm it holds on a period the tuning never saw.
Sources & Further Reading
Want to go deeper? These independent, authoritative sources shaped this guide — each one is worth reading in full:
The Cross-Market Desk is the SignalBots editorial team for topics that span every market — platform connectors, copy trading, partnership and IB programs, and the general mechanics of trading automation. We research and write the guides that apply no matter what you trade.
Discussions 0
Leave a comment