Reading a chart and coding a chart are separated by one sentence, and the sentence always breaks in the same place.

The last down candle before the move is a phrase you understand instantly and a compiler understands not at all. Which down candle, the one immediately before or the last one in the pullback? How far back does "before" reach? How large does the move have to be before it counts as a move at all?

Closing that gap is the whole job here. Not what an order block is, and not what a fair value gap looks like on a chart - you already mark both by eye. What you need is the test: a deterministic comparison an expert advisor can run against an array of prices on every closed bar and get the same answer from every single time.

By the end of this page you will have both detection rules at pseudocode precision, the filters that stop a detector from firing on chart noise, the mitigation check that retires a zone once price has consumed it, and the MQL5 shape that holds all of it together.

Key Takeaways
  • An order block is three conditions, not one shape: a candidate candle, an impulse that closes fully beyond it and passes both a body-ratio and an ATR size test, and a close beyond the prior swing that proves the move achieved something.
  • A fair value gap is one comparison: low[i] > high[i-2] for a bullish gap. The middle candle never appears in the test - it only earns its place through a displacement filter you add on top.
  • Detection is a boolean plus a lifecycle. Store mitigated and invalidated as separate states and cap zones by age, or the EA will keep trading levels price has already spent.
  • Of the four order-block definitions in circulation, only displacement-plus-structure-break uses unit-free parameters - which is why its constants survive a move to another symbol or timeframe.
Table of Contents (24 min read)Contents

What "Detection" Means to an Algorithm

You look at a chart and see a situation. An EA running on MetaTrader 5 sees something far poorer: an ordered array of bars, each holding an open, a high, a low, a close, a timestamp and a tick count, plus an integer index pointing at one of them. There is no "obvious", no "clean", no "before the move".

One word deserves clearing up first. What ships inside an order-block indicator or an ICT expert advisor is almost never a learned model. It is deterministic pattern-matching: fixed comparisons over a fixed window, returning the same output for the same input every single time. That is a strength rather than a limitation - a rule you can read, argue with and tune beats a black box that cannot tell you why it drew a box on your chart.

So every rule you write has to answer three questions your eye answers silently:

  • The window. How many bars does the test look at, and where is it anchored - a fixed offset from the current bar, or a search backwards until some condition is met?
  • The test. A boolean built only from arithmetic on those six fields. If a condition cannot be expressed as a comparison between numbers you already hold, it is not part of the rule yet.
  • The lifecycle. What happens to the result after it becomes true. You forget a zone the moment it stops mattering. An EA keeps it forever unless you tell it exactly when to let go.

The third question is the one most first detectors never answer, and it is why a detector that looks correct on a screenshot behaves badly across a month of bars. Detection is not a single boolean. It is a boolean plus an object with a state.

One rule sits above all three: evaluate on closed bars only. A forming bar's high, low and close all move, so a test run on ticks flips between true and false inside the same candle - the detector paints a zone, erases it, paints it again a minute later. That is repainting, and it makes live behaviour and historical testing equally untrustworthy. Read from index 1 backwards; never let a rule see index 0.

Two upright glass panes: the front one etched with a candlestick chart, the one behind it etched with the same data as a plain grid of cells.
The detector never sees the pattern you see - only open, high, low and close, one row per bar.

How the Algorithm Flags a Bullish or Bearish Order Block

Three things have to line up before a candle earns the label: a candidate candle, an impulse that leaves it behind, and a structural consequence proving the impulse went somewhere. Skip any one of them and the rule over-fires.

The Candlestick and Displacement Conditions

Take i as the impulse candle and i-1 as the candidate. A bullish order block requires all five of these to hold at the same time:

  • close[i-1] < open[i-1] - the candidate is a down candle.
  • close[i] > open[i] - the next candle is an up candle.
  • close[i] > high[i-1] - it closes fully beyond the candidate, wick included. A close that only reaches into the candidate's body is continuation, not departure.
  • body[i] >= k * body[i-1] - the impulse is large relative to what it replaced.
  • high[i] - low[i] >= m * ATR(n) - and large relative to the instrument's current volatility.

The bearish rule is the same five comparisons with every inequality flipped: an up candle, a down candle that closes below its low, and the same two size tests.

Displacement test - is this move big enough to count?
bodyik · bodyi−1    and    rangeim · ATRn
where body = |close − open|, range = high − low, i is the impulse candle and i−1 the order-block candidate. Start at k = 1.5, m = 1.0, n = 14 and tune per timeframe. A 14-pip candidate body then needs a 21-pip impulse body and a full-ATR impulse range.
Two size tests, not one: the ratio makes the move big for this chart, the ATR multiple makes it big for this market.

Both size tests have to be there, and builders routinely ship only one. The ratio test alone passes a five-pip candle that follows a doji, because five pips really is fifteen times a one-pip body. The ATR test alone passes a perfectly ordinary candle during a fast session, because ordinary is large when volatility is high. Together they mean large for this chart, and large for this market right now - which is what your eye was actually judging all along.

One decision left, and it is not cosmetic: which prices become the zone's edges. Use the candidate's full range (high[i-1] to low[i-1]) for a wider zone that price is more likely to reach, or its body only (open[i-1] to close[i-1]) for a tighter one that produces better reward-to-risk when it does. Either is defensible. What is not defensible is mixing them, because the mitigation check later compares price against whichever edge you stored - pick one convention and use it in both places.

Confirming It With a Structure Break

Displacement proves the move was big. It does not prove the move achieved anything. That is what the structure test adds, and it is the single condition that separates an order-block detector from a large-candle detector:

close[i] > swing_high_before(i) for a bullish block, close[i] < swing_low_before(i) for a bearish one.

A swing point has to be mechanical too. The cheapest workable definition is a fractal: high[j] is a swing high when it is the highest high of the L bars on either side of it, with L of 2 or 3 being enough on intraday data. Note the consequence - a swing needs L bars to its right before it can be confirmed, so the most recent confirmable swing is always a few bars old. That lag is a feature, and it is the same logic that makes a confirmation candle worth waiting for.

Here is the trap that silently ruins this test: freeze the reference before you evaluate the break. If your swing scan includes the impulse candle itself, the impulse's own high becomes the swing it is supposed to break, the comparison degenerates, and effectively every candle passes. Compute swings from bars that closed strictly before the candidate, cache that value, then compare.

This is one confirmation step inside the order-block rule, not the full break-of-structure methodology. Deciding whether a break represents a genuine trend change or a liquidity grab dressed as one is a larger question with its own answer.

How the Algorithm Flags a Bullish or Bearish Fair Value Gap

After the order block, this rule feels almost unfairly easy. It needs no lookback search, no swing detection, and no memory of anything except three consecutive candles.

The Three-Candle Imbalance Test

With i as the most recently closed candle:

  • Bullish gap: low[i] > high[i-2]. The zone is the band from high[i-2] up to low[i].
  • Bearish gap: high[i] < low[i-2]. The zone is the band from high[i] up to low[i-2].

That is the entire structural test. Notice what is not in it: the middle candle. Candle i-1 creates the imbalance by moving fast enough that its neighbours' ranges stop overlapping, but its own prices never appear in the comparison. Only the outer two candles' extremes decide whether a gap exists.

Notice also what this is not. It is not a price gap in the usual sense - the market traded continuously through every price in that band. What is missing is the overlap between the first and third candle's ranges, not the trades themselves. Getting that distinction right matters when you code it, because a real weekend gap will also satisfy the inequality and it is not the thing you are hunting.

Filtering Out Weak Gaps

Run the raw test on a one-minute chart and it fires dozens of times an hour. Three filters, applied in this order, cut that down to something a strategy can consume.

  1. Size against ATR. Require gap_size >= f * ATR(n), starting around f = 0.25. Expressing the floor as an ATR multiple rather than a point value is what lets the same constant work on EUR/USD and on XAU/USD without a per-symbol table.
  2. Displacement on the middle candle. Apply the same body-versus-ATR test from the order-block rule to candle i-1. This is the filter most implementations skip, and skipping it is the largest single source of false signals in an FVG detector: two quiet candles whose wicks merely happen not to overlap produce a technically valid gap with no impulse behind it.
  3. A spread floor. A gap only a few times the typical spread is not tradable even when it is real, because the fill consumes it. Reject anything below a small multiple of the symbol's normal spread before it ever reaches the zone array.

One artifact worth guarding against on FX: the three-candle window that straddles the broker's daily rollover produces gaps that reflect the session break rather than any imbalance. Exclude triplets whose middle candle spans that boundary, or you will spend an afternoon debugging a cluster of zones that all appear at the same time of day.

A Worked Example on Real Numbers

Rules are easier to trust once you have watched them run. Here is an illustrative EUR/USD 15-minute sequence - hypothetical prices, chosen so both rules fire on the same impulse.

Worked example
One impulse candle creates both zones at once EUR/USD 15m
The gap's lower edge and the block's upper edge are the same price, 1.0846 - both are the candidate candle's high.

Walk the order-block rule first, with candle 8 as the impulse and candle 7 as the candidate:

  • Candidate is a down candle: opens 1.0845, closes 1.0831. Its body is 14 pips.
  • Impulse is an up candle: opens 1.0831, closes 1.0874. Its body is 43 pips, which is roughly 3.1x the candidate's - comfortably past a k of 1.5.
  • The impulse closes at 1.0874, above the candidate's high of 1.0846. Fully beyond, not a wick-through.
  • The last confirmed swing high before the impulse sits at 1.0866. The impulse closes above it, so the structure test passes.
  • The zone is stored as 1.0828 to 1.0846, the candidate's full range.

Now the gap rule, with candle 9 as i:

  • low[9] is 1.0869 and high[7] is 1.0846, so low[i] > high[i-2] holds. The gap spans 23 pips.
  • With ATR sitting near 18 pips, an f of 0.25 sets the floor at about 4.5 pips. The gap clears it easily.
  • The middle candle is the same 43-pip impulse, so the displacement filter passes without a second calculation.

Two details in that walk are worth carrying into your own build. First, the gap's lower edge and the block's upper edge are the same number, 1.0846, because both are anchored to the candidate candle's high - whenever an order block sits at the base of an impulse, the fair value gap created by that impulse begins exactly where the block ends. Second, the block is confirmed one bar earlier than the gap, because the gap needs a third candle to exist. Your detector will always publish the block first, and code that assumes both arrive together will drop half its gaps.

Later in the sequence, candle 12 trades down to 1.0844. That is below the gap's lower edge, which means the imbalance has been fully filled, and it is inside the block's upper edge, which starts that zone's own clock running. Which of those counts as "used up" is the next question.

Which Order Block Definition Should You Actually Code?

Search for an order-block implementation and you will find four distinct families of rule, presented with equal confidence and never compared against each other. They genuinely disagree, so choosing between them is a real decision rather than a matter of taste.

Reconciling the definitions
DefinitionWhat it keys offTravels across markets?Main failure mode
Fixed candle pattern A run of same-direction candles meeting body-percentage thresholds No - thresholds are hand-tuned to one symbol and timeframe Marks zones inside dead ranges, because nothing in the rule requires a move
Volume-weighted A volume spike on the candidate or the impulse candle No - MT5 reports broker tick volume on FX, not traded volume Results shift when the data feed or broker changes, so nothing reproduces
Consolidation-range breakout A tight N-bar range, then a close beyond it plus an impulse threshold Partly - the range tolerance is expressed in points Misses blocks that form mid-trend, because a range has to exist first
Displacement + structure break A body ratio, an ATR multiple, and a close beyond the prior swing Yes - every parameter is a dimensionless ratio Fires late: nothing can be confirmed until the impulse candle closes
Only the last row's parameters are unit-free, which is why the same constants survive a move from EUR/USD 15m to XAU/USD 4H.

Code the displacement-plus-structure-break rule. Three reasons, in order of how much they will matter to you six months in.

Its parameters are dimensionless. A body ratio and an ATR multiple carry no units, so the constants that work on EUR/USD 15-minute bars still behave sensibly on gold 4-hour bars. Every other family embeds either a point value, a percentage tuned to one instrument, or a volume field whose meaning changes with the data source - and each of those turns into a tuning table you maintain forever.

Its trigger is causal rather than cosmetic. A candle-count pattern describes what the chart looked like; displacement plus a structure break describes what actually happened - a fast repricing that left prior structure behind. When you later debug a bad zone, a causal rule tells you which condition was too loose. A cosmetic one tells you nothing.

And it degrades gracefully. Drop the structure test and you still have a defensible momentum zone, just a noisier one - which makes it a usable knob when a symbol gives you too few zones to work with. Drop the range detection out of the breakout family and you have nothing left at all.

The one genuine cost is timing: nothing can be confirmed until the impulse candle closes, so the zone appears one bar after a discretionary trader would have drawn it. That is the price of a rule that does not repaint, and it is worth paying.

The Mitigation Rule That Retires a Zone

A zone that stays armed forever is worse than no zone at all, because it will eventually hand your EA a setup at a level price has already spent. "Mitigated" has at least four plausible codeable meanings, and the one you pick changes how often the detector speaks:

  • Touch. Any wick into the zone retires it. Most conservative, kills zones fastest.
  • Midpoint. Price trades through the zone's equilibrium, halfway between the edges.
  • Full fill. Price traverses the far edge.
  • Close-through. A bar closes beyond the far edge.

That last one is not the same kind of event as the other three, and collapsing them is a mistake worth avoiding. A zone that price entered, reacted from, and left behind has been mitigated - it did its job and is now spent. A zone that price closed straight through has been invalidated - the premise was wrong. Both stop signal generation, so it is tempting to store one dead flag. Store two states instead: when you later review why the detector underperformed, only the invalidated count tells you the rule needs work.

A workable default set: touch for fair value gaps, since a gap's entire thesis is that it gets filled; midpoint for order blocks, which gives price room to wick without retiring a zone that is still working; and close-through as the invalidation trigger for both. Add one non-price condition on top - an age cap in bars - because an unmitigated block from four hundred bars ago is a stale signal whatever the price action says.

stateDiagram-v2
    [*] --> Candidate: closed bar matches the pattern
    Candidate --> Rejected: displacement or size filter fails
    Candidate --> Pending: pattern valid, structure not broken yet
    Pending --> Active: impulse closes beyond the prior swing
    Pending --> Rejected: no break inside the confirmation window
    Active --> Mitigated: price trades back into the zone
    Active --> Invalidated: a bar closes beyond the far edge
    Active --> Expired: zone older than the age cap
    Mitigated --> [*]
    Invalidated --> [*]
    Expired --> [*]
    Rejected --> [*]

    
Only Active zones may produce a signal. Keep Mitigated and Invalidated apart: one means the zone worked, the other means your rule was wrong.

Implementation note: the forward scan runs once per closed bar and only over zones still in the active state. Re-scanning the whole history on every tick is the most common way a working detector turns into an EA that misses fills.

Storing and Scanning Zones Inside an MT5 EA

Everything above is arithmetic. What turns it into a component the rest of your EA can consume is the container the zones live in and the loop that maintains them.

The detection pass, run once per closed bar

  1. 1
    Wait for the bar to close

    Compare the newest bar's open time with the one you stored. Same value means the candle is still forming, so return and do nothing.

  2. 2
    Refresh volatility and structure

    Copy the current ATR value and recompute swing highs and lows from closed bars only, freezing the reference before any test runs.

  3. 3
    Test the newest candles

    Run the order-block rule on the last two closed bars and the fair-value-gap rule on the last three. Both are pure arithmetic on OHLC.

  4. 4
    Store what survives

    Append each surviving zone with its edges, direction, creation bar and an active state. Nothing else in the EA writes to that array.

  5. 5
    Scan the active zones

    Walk the active zones once, marking each mitigated, invalidated or expired, then expose the survivors to the rest of the EA.

Five stages, one pass. Everything expensive happens on bar close, so a busy tick stream costs you nothing.

The container itself stays deliberately boring. One struct per zone, one dynamic array of them, one enum for state. Order blocks and gaps share the array with a kind field rather than living in separate structures, because every downstream consumer - entry logic, risk sizing, the chart overlay - wants to ask "what zones are active near price" without caring which pattern produced them.

mql5 zone_detector.mqh
enum ZONE_KIND  { ZONE_OB, ZONE_FVG };
enum ZONE_STATE { ZONE_ACTIVE, ZONE_MITIGATED, ZONE_INVALID, ZONE_EXPIRED };

struct Zone
  {
   ZONE_KIND   kind;
   int         dir;          // +1 bullish, -1 bearish
   double      top, bottom;  // the two edges, whatever convention you chose
   int         created_bar;
   ZONE_STATE  state;
  };

Zone     g_zones[];
int      g_atr;
datetime g_last_bar = 0;

void OnTick()
  {
   datetime t[];
   if(CopyTime(_Symbol, _Period, 0, 1, t) != 1 || t[0] == g_last_bar)
      return;                       // still inside the forming bar
   g_last_bar = t[0];

   MqlRates r[];
   ArraySetAsSeries(r, true);
   if(CopyRates(_Symbol, _Period, 1, InpLookback, r) < InpLookback)
      return;                       // r[0] is the last CLOSED bar

   double atr[];
   ArraySetAsSeries(atr, true);
   if(CopyBuffer(g_atr, 0, 1, 1, atr) != 1)
      return;

   DetectOrderBlock(r, atr[0]);
   DetectFairValueGap(r, atr[0]);
   UpdateZones(r[0]);
  }

// r[0] = impulse candle, r[1] = order-block candidate
void DetectOrderBlock(const MqlRates &r[], double atr)
  {
   double base    = MathAbs(r[1].close - r[1].open);
   double impulse = MathAbs(r[0].close - r[0].open);

   bool bullish = r[1].close < r[1].open
               && r[0].close > r[0].open
               && r[0].close > r[1].high
               && impulse   >= InpBodyRatio * base
               && (r[0].high - r[0].low) >= InpAtrMult * atr
               && r[0].close > SwingHighBefore(r, 1);   // structure break

   if(bullish)
      AddZone(ZONE_OB, +1, r[1].high, r[1].low);
   // bearish mirror: flip every comparison and call SwingLowBefore()
  }

// r[0] = third candle, r[1] = displacement candle, r[2] = first candle
void DetectFairValueGap(const MqlRates &r[], double atr)
  {
   if(r[0].low <= r[2].high)                       // no bullish imbalance
      return;

   double gap = r[0].low - r[2].high;
   double mid = MathAbs(r[1].close - r[1].open);

   if(gap >= InpGapAtrMult * atr && mid >= InpAtrMult * atr)
      AddZone(ZONE_FVG, +1, r[0].low, r[2].high);
   // bearish mirror: r[0].high < r[2].low
  }
AddZone(), UpdateZones() and SwingHighBefore() are elided; the shape that matters is the closed-bar guard and the single shared array.

Three things in that skeleton are load-bearing. CopyRates starts at index 1, so r[0] is always the last closed bar and the forming candle is unreachable by construction rather than by discipline. The two detectors index the same array differently - the order-block rule reads r[0] as its impulse while the gap rule reads r[0] as its third candle - which is exactly the one-bar offset the worked example showed. And UpdateZones receives only the newest closed bar, because a zone's state can only change on a bar that has actually printed.

If you prefer a class over a struct, the MQL4 and MQL5 object model supports it and an object-oriented engine reads better once you add per-zone drawing and per-symbol instances. The detection logic does not change; only the container does.

Before You Trust the Detector

A detector can be wrong in ways a chart screenshot will never reveal - it can look perfect on the fifty bars you are watching and be systematically broken on the fifty thousand you are not. These are the checks that catch that class of failure.

Detector validation - tick each one before you wire it to an entry

0 / 10

Checklist complete — you’re cleared to proceed.

Nine of these are cheap to check in an afternoon. The one that costs real time - reload reproducibility - is the one that saves a wasted backtest.

Where Order Blocks and Fair Value Gaps Fit in the Full Automation Build

What you have now is one component: a function that turns bars into a maintained set of live price zones. It is an input, not a strategy, and it is deliberately narrow so that everything else can consume it.

Downstream, that zone array is what the rest of the build reads from. Entry construction draws its retracement zone between the impulse and the structure it broke, using the block you just detected as the anchor. Sweep detection asks whether the liquidity taken before the impulse was the setup or the trap. A session filter decides whether a zone formed inside a window you actually trade. A daily-cycle model asks where in the day's accumulation-manipulation-distribution rhythm the zone appeared. Structure-shift logic decides whether the break that confirmed your block was the meaningful one. Each of those is its own rule-set with its own failure modes, and each assumes detection is already solved - which is what makes the detector the sensible first thing to build in the full ICT-to-EA automation workflow.

Two boundaries are worth stating plainly. A detector that is correct is not a detector that is profitable - whether these zones carry an edge on your symbol is a backtesting question, answered with out-of-sample data and realistic costs, not by how good the zones look on a chart. And a correct zone still says nothing about how much to risk on it; position sizing, stop placement and the ordinary risks of leveraged trading sit entirely outside this component.

Build the detector first, prove it reproduces, then let the interesting arguments happen in the layer above it.

FAQ

Should the detector run on every tick or only on closed bars?

Closed bars only, without exception for the detection rules. A forming candle's high, low and close all change until the moment it closes, so a rule evaluated on ticks will mark a zone and then unmark it within the same candle. Tick-level code still has a place in an EA - checking whether price has reached an already-confirmed zone, managing an open position - but nothing that creates a zone should ever read the forming bar.

Do order blocks and fair value gaps need different mitigation rules?

In practice, yes. A fair value gap's whole premise is that the imbalance gets filled, so a touch or a fill is the natural retirement condition. An order block is meant to be revisited and reacted from, so retiring it on the first wick throws away zones that are still working - a midpoint rule holds up better. Whichever pair you choose, apply them against the same edges you stored at detection time.

How many zones should the EA keep in memory?

Fewer than you would guess. Cap the array by both age and count: retire zones older than a fixed number of bars for the timeframe, and keep only the nearest handful on each side of current price. A detector holding hundreds of zones is usually one whose filters are too loose, and the memory cost is the least of the problems that creates.

Can the same rules run on MT4?

The logic ports directly, since it is arithmetic on OHLC values that both platforms expose. What changes is the plumbing: MQL4 has no MqlRates array copy in the same form and no native struct array handling as convenient as MQL5's, so you end up with parallel arrays or a class wrapper. If you are choosing now, write it for MQL5 - the struct-plus-array shape above is meaningfully cleaner there.

Why do my zones move when I reload the chart?

Almost always because something in the detection path reads the forming bar, or because the swing scan includes the candle being tested. Both produce results that depend on when the code ran rather than on the bar data alone, so a reload - which replays history without the live tick sequence - gives different answers. Fix the index offsets and the results become reproducible, which is the precondition for any testing being meaningful.

Sources & Further Reading

Want to go deeper? These independent, authoritative sources shaped this guide — each one is worth reading in full:

Signalbots Cross-Market Desk

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.

More from this desk

Discussions 0

Leave a comment