Swing Detector by Pullback (Smart Money Concepts)
Almost every structure-based method starts with the same question: which highs and lows actually matter? Everything downstream — market structure, BOS and CHoCH, order blocks, liquidity levels — is built on top of that one answer, so the definition you pick decides what the rest of your analysis sees.
There are several common ways to answer it. The best known is the 3-candle pattern (the fractal): a candle whose high is above both neighbours is a swing high. It is symmetric, trivial to compute, and it is what most swing indicators use. Others widen the window to 5 or 7 candles, or filter candidates by a minimum move in points or ATR.
This indicator uses a different definition — the pullback definition used in Trading Hub 3.0. Instead of asking whether a candle is taller than its neighbours, it asks whether price came back and rejected that candle. A high becomes a swing only once the market trades back through the low of the candle that made it. Nothing is decided by shape; everything is decided by what price does afterwards.
The practical difference: a fractal describes geometry, while a pullback describes an event. Because a pullback is an event, it also tells you when the swing became valid — and that timestamp is what makes a non-repainting implementation possible.
How the rule works
The detector is never looking for both sides at once. It searches for one side at a time, and it holds exactly three pieces of information:
| State | Meaning |
|---|---|
| Direction | which side is currently being searched for — a high or a low |
| Tracked extreme | the highest high (or lowest low) seen so far in the current leg |
| Reference level | the opposite edge of the candle that made that extreme |
The reference is the important part. When a candle sets a new highest high, that same candle also supplies the level that will later confirm it: its own low. Price must come back through that low for the high to count.
Reading the figure:
- A — a leg opens. The tracked high and the reference low both come from one candle.
- B — every higher high replaces both values. The high is still provisional; nothing is drawn.
- C — a candle finally trades below the reference low. That is the pullback, and it confirms the tracked high as a swing high.
- D — the confirming candle immediately opens the search for a low, mirrored: track the lowest low, the reference is its high, a break above confirms it.
Because confirming one side always flips the search to the other, swing highs and lows alternate automatically. There is no separate rule enforcing it and no comparison against earlier swings.
The rules, candle by candle
On every closed candle the detector asks two independent questions: did this candle make a new extreme? and did it break the reference? Four combinations are possible per direction.
| # | Current trend | Current candle | Action 1 | Action 2 |
|---|---|---|---|---|
| 1 | Looking for a HIGH | Inside bar — no new high, reference low intact | nothing | nothing |
| 2 | Looking for a HIGH | New high, reference low intact | tracked high = this candle's high | reference low = this candle's low |
| 3 | Looking for a HIGH | Break — no new high, but trades below the reference low | confirm the tracked high as a swing high | flip to LOW: tracked low = this candle's low, reference high = this candle's high |
| 4 | Looking for a HIGH | Outside bar — new high and breaks the reference low | confirm this candle's own high as a swing high | flip to LOW: tracked low = this candle's low, reference high = this candle's high |
| 5 | Looking for a LOW | Inside bar — no new low, reference high intact | nothing | nothing |
| 6 | Looking for a LOW | New low, reference high intact | tracked low = this candle's low | reference high = this candle's high |
| 7 | Looking for a LOW | Break — no new low, but trades above the reference high | confirm the tracked low as a swing low | flip to HIGH: tracked high = this candle's high, reference low = this candle's low |
| 8 | Looking for a LOW | Outside bar — new low and breaks the reference high | confirm this candle's own low as a swing low | flip to HIGH: tracked high = this candle's high, reference low = this candle's low |
Rows 1–4 and 5–8 are exact mirrors, which is why the whole engine is only a few dozen lines.
The rare case: a candle that breaks both directions
Rows 4 and 8 deserve their own explanation. Usually a candle either extends the leg or breaks the reference. An outside bar can do both at once — it makes a new high and trades below the reference low in the same candle.
Both rules then claim it, and they contradict each other. Treating it as an extension throws away a real break; treating it as a break would confirm a high that this very candle has already exceeded.
The resolution is to let the candle be the swing itself:
- Its own high is confirmed as the swing high — not the older tracked high, which it has just taken out.
- The same candle then opens the opposite leg: the tracked low becomes its low and the reference becomes its high.
So one candle carries both a swing high and the start of the low leg. On the chart this shows up as a marker above and below the same bar, with a vertical leg between them — which is exactly what happened inside that candle.
This is also why the legs are drawn as trend-line objects rather than a DRAW_SECTION buffer. A section plot stores one value per bar and could not hold two points on the same candle.
It does not repaint
The tracked extreme is provisional and is never drawn. A swing is written to the chart only on the candle that confirms it, and once written it is never moved, recoloured or removed. Only closed candles are evaluated; the forming candle is ignored entirely.
The trade-off is honest and worth stating: confirmation lag. The newest swing appears only after the break that validates it. If you want a marker the instant a new high prints, this is the wrong tool — that marker would have to move later, which is repainting.
Inputs
Detection
| Input | Default | What it does |
|---|---|---|
| Start by looking for | Looking for a swing high | Which side the very first leg searches for. It only affects the oldest bars on the chart; after the first confirmation the direction is driven entirely by the breaks. Leave it alone unless the first swing on your history looks wrong. |
| Break confirmed by | Wick | Wick uses the candle's high/low — the literal Trading Hub rule, and the more sensitive setting. Close requires the candle to close through the reference, which ignores single-wick pokes and gives noticeably fewer, larger swings. |
How to choose: start with Wick. If you are working on M1–M15, or on a symbol with long noisy wicks such as gold or an index CFD, switch to Close — it removes most of the swings caused by a single spike without changing the logic anywhere else. A higher timeframe is the other way to get larger structure; the rule itself has no sensitivity or period setting to tune.
Display
| Input | Default | What it does |
|---|---|---|
| Draw the swing markers | true | Turns the arrows on or off. The buffers stay filled either way, so iCustom still returns every swing. |
| Arrow code — swing high / low | 217 / 218 | Wingdings codes. Common alternatives: 234/233, 241/242, 159 for dots. |
| Arrow distance from the bar | 10 | Gap in pixels between the candle and its arrow. Increase it on dense charts. |
| Draw the legs between swings | true | The dotted zig-zag connecting consecutive swings. Turn it off if you already run a zig-zag. |
| Leg colour / style / width | silver, dotted, 1 | Appearance of the legs. |
Turning both markers and legs off leaves a purely computational instance — useful when an EA reads the buffers and you do not want anything on the chart.
Alerts
| Input | Default | What it does |
|---|---|---|
| Popup alert on a confirmed swing | false | Terminal popup when a swing is confirmed. |
| Push notification on a confirmed swing | false | Push to the MetaQuotes ID set in Tools → Options → Notifications. |
Alerts fire only on live updates, never while the indicator loads history, and only once per swing.
Using it from an EA
Two buffers, both non-empty only on confirmed swings (empty value is 0.0):
| Buffer | Contents |
|---|---|
| 0 | price of a confirmed swing high |
| 1 | price of a confirmed swing low |
int h = iCustom(_Symbol, _Period, "SMC_Pullback");
double buf[];
CopyBuffer(h, 0, 0, 100, buf); // swing highsThe detection engine lives in a single self-contained class, CPullbackDetector, with no chart or buffer dependencies — it takes the price arrays and reports confirmed swings, so it can be copied straight into an EA if you would rather not go through iCustom.
What it deliberately does not do
This indicator stops at the swing points. It does not label HH/HL/LH/LL, and it does not derive BOS, CHoCH, IDM, order blocks or liquidity levels. Those are separate steps that need a reliable set of swings first — which is exactly what this provides.
The project is supported on MQL5 Algo Forge. You can find the code and next updates at below link.
The repo is here: https://forge.mql5.io/SandroBegashvil/SMC-Pullback-CodeBase
Source code
Copy the code into MetaEditor, save it as SMC_Pullback_v1_0.mq5 and compile (F7).
//+------------------------------------------------------------------+
//| SMC Pullback v1.0.mq5 |
//| Copyright 2026, Sandro Begashvili |
//| |
//| Swing detection by the pullback (Trading Hub 3.0) rule. |
//| |
//| The detector looks for one side at a time. While looking for a |
//| HIGH it tracks the highest high; the candle that made it also |
//| supplies the reference level - its own low. A later candle that |
//| breaks that low confirms the tracked high as a swing, and the |
//| search flips to the LOW side, seeded from the breaking candle. |
//| Looking for a LOW is the mirror image. |
//| |
//| One candle can make a new extreme AND break the reference. It is |
//| then the swing itself, and it seeds the opposite leg as well. |
//| |
//| Does not repaint: the tracked extreme is never drawn, and a |
//| swing is drawn only on the candle that confirms it, then never |
//| moved. The cost is confirmation lag - the newest swing appears |
//| only once the break confirming it has happened. |
//+------------------------------------------------------------------+
#property copyright "Copyright 2026, Sandro Begashvili"
#property link "https://www.mql5.com/en/users/sandrobegashvil"
#property version "1.00"
#property description "Swing high / low detection by the pullback rule of Trading Hub 3.0. Does not repaint."
#property indicator_chart_window
#property indicator_buffers 2
#property indicator_plots 2
#property indicator_label1 "Pullback High"
#property indicator_type1 DRAW_ARROW
#property indicator_color1 clrOrangeRed
#property indicator_width1 1
#property indicator_label2 "Pullback Low"
#property indicator_type2 DRAW_ARROW
#property indicator_color2 clrDodgerBlue
#property indicator_width2 1
//--- side the detector is currently searching for
enum ENUM_LOOKING_FOR
{
LOOK_HIGH, // Looking for a swing high
LOOK_LOW // Looking for a swing low
};
//--- what counts as breaking the reference level
enum ENUM_BREAK_MODE
{
BREAK_BY_WICK, // Wick - the high / low of the candle
BREAK_BY_CLOSE // Close - the close of the candle
};
input group "Detection"
input ENUM_LOOKING_FOR InpStartSide = LOOK_HIGH; // Start by looking for
input ENUM_BREAK_MODE InpBreakMode = BREAK_BY_WICK; // Break confirmed by
input group "Display"
input bool InpShowMarkers = true; // Draw the swing markers
input int InpArrowHigh = 217; // Arrow code - swing high
input int InpArrowLow = 218; // Arrow code - swing low
input int InpArrowShift = 10; // Arrow distance from the bar (pixels)
input bool InpShowLegs = true; // Draw the legs between swings
input color InpLegColor = clrSilver; // Leg colour
input ENUM_LINE_STYLE InpLegStyle = STYLE_DOT; // Leg style
input int InpLegWidth = 1; // Leg width
input group "Alerts"
input bool InpAlertPopup = false; // Popup alert on a confirmed swing
input bool InpAlertPush = false; // Push notification on a confirmed swing
#define SMC_EMPTY 0.0
double BufHigh[];
double BufLow[];
//+------------------------------------------------------------------+
//| One confirmed swing |
//+------------------------------------------------------------------+
struct SwingPoint
{
int index; // bar index in the OnCalculate arrays (0 = oldest)
double price; // extreme price of the swing candle
bool is_high; // side
};
//+------------------------------------------------------------------+
//| Detection engine |
//| |
//| Pure logic - no buffers, no chart objects, so it can be lifted |
//| into an EA unchanged. A swing leaves the class exactly once, |
//| through `confirmed`, and is final at that moment. |
//+------------------------------------------------------------------+
class CPullbackDetector
{
private:
ENUM_BREAK_MODE m_break_mode;
bool m_looking_high; // side being searched for
bool m_started; // first leg seeded
int m_ext_index; // candle holding the tracked extreme
double m_ext_price; // tracked extreme: highest high or lowest low
double m_ref_price; // level whose break confirms the extreme
int m_next_bar; // next bar to evaluate
//--- (re)starts a leg on bar i: that candle is both the tracked
//--- extreme and the source of the reference level
void StartLeg(const bool up, const int i, const double &h[], const double &l[])
{
m_looking_high = up;
m_ext_index = i;
m_ext_price = up ? h[i] : l[i];
m_ref_price = up ? l[i] : h[i];
}
void Confirm(const int i, const double price, SwingPoint &out[])
{
const int n = ArraySize(out);
ArrayResize(out, n + 1);
out[n].index = i;
out[n].price = price;
out[n].is_high = m_looking_high;
}
public:
void Configure(const ENUM_LOOKING_FOR side, const ENUM_BREAK_MODE mode)
{
m_break_mode = mode;
m_looking_high = (side == LOOK_HIGH);
m_started = false;
m_ext_index = -1;
m_ext_price = 0.0;
m_ref_price = 0.0;
m_next_bar = 0;
}
bool LookingHigh(void) const { return(m_looking_high); }
double Reference(void) const { return(m_ref_price); }
void Update(const int rates_total, const double &h[], const double &l[],
const double &c[], SwingPoint &confirmed[]);
};
//+------------------------------------------------------------------+
//| Evaluates every closed bar not seen yet and appends the swings |
//| that became final during this call. |
//+------------------------------------------------------------------+
void CPullbackDetector::Update(const int rates_total, const double &h[], const double &l[],
const double &c[], SwingPoint &confirmed[])
{
ArrayResize(confirmed, 0);
const int last_closed = rates_total - 2; // the forming bar is never used
for(; m_next_bar <= last_closed; m_next_bar++)
{
const int i = m_next_bar;
if(!m_started) // seed the very first leg
{
StartLeg(m_looking_high, i, h, l);
m_started = true;
continue;
}
//--- both tests use the state as it stands on entry, so extending the
//--- leg cannot hide a break of the reference that is still in force
const bool extreme = m_looking_high ? (h[i] > m_ext_price) : (l[i] < m_ext_price);
const double level = (m_break_mode == BREAK_BY_CLOSE) ? c[i]
: (m_looking_high ? l[i] : h[i]);
const bool broke = m_looking_high ? (level < m_ref_price) : (level > m_ref_price);
if(extreme && broke) // outside bar: it is the swing itself
Confirm(i, m_looking_high ? h[i] : l[i], confirmed);
else
if(extreme) // leg extends, this candle is the new reference
{
StartLeg(m_looking_high, i, h, l);
continue;
}
else
if(broke) // the tracked extreme is confirmed
Confirm(m_ext_index, m_ext_price, confirmed);
else
continue; // inside bar: nothing changes
StartLeg(!m_looking_high, i, h, l); // this candle opens the opposite leg
}
}
//+------------------------------------------------------------------+
//| Chart layer |
//+------------------------------------------------------------------+
CPullbackDetector Detector;
string LegPrefix = ""; // unique per chart, so instances cannot collide
datetime LastAlertTime = 0;
bool HasPrevSwing = false; // tail of the leg chain
datetime PrevSwingTime = 0;
double PrevSwingPrice = 0.0;
long LegSerial = 0;
//+------------------------------------------------------------------+
void ResetLegs(void)
{
ObjectsDeleteAll(0, LegPrefix);
HasPrevSwing = false;
LegSerial = 0;
}
//+------------------------------------------------------------------+
int OnInit(void)
{
LegPrefix = StringFormat("SMCPB_%I64d_", ChartID());
SetIndexBuffer(0, BufHigh, INDICATOR_DATA);
SetIndexBuffer(1, BufLow, INDICATOR_DATA);
for(int p = 0; p < 2; p++)
{
PlotIndexSetDouble(p, PLOT_EMPTY_VALUE, SMC_EMPTY);
PlotIndexSetInteger(p, PLOT_DRAW_BEGIN, 0);
//--- the buffers are filled either way, so iCustom still sees every swing
PlotIndexSetInteger(p, PLOT_DRAW_TYPE, InpShowMarkers ? DRAW_ARROW : DRAW_NONE);
}
PlotIndexSetInteger(0, PLOT_ARROW, InpArrowHigh);
PlotIndexSetInteger(1, PLOT_ARROW, InpArrowLow);
PlotIndexSetInteger(0, PLOT_ARROW_SHIFT, -InpArrowShift);
PlotIndexSetInteger(1, PLOT_ARROW_SHIFT, InpArrowShift);
IndicatorSetString(INDICATOR_SHORTNAME, "SMC Pullback");
IndicatorSetInteger(INDICATOR_DIGITS, _Digits);
Detector.Configure(InpStartSide, InpBreakMode);
LastAlertTime = 0;
ResetLegs();
return(INIT_SUCCEEDED);
}
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
{
ObjectsDeleteAll(0, LegPrefix);
ChartRedraw();
}
//+------------------------------------------------------------------+
//| Draws the leg from the previous swing to this one. |
//| |
//| Legs cannot live in an indicator buffer: the outside-bar case |
//| puts a high and a low on the SAME candle, and a DRAW_SECTION |
//| plot holds one value per bar, so the second point would |
//| overwrite the first. With trend lines that leg is just vertical. |
//+------------------------------------------------------------------+
void DrawLeg(const datetime t, const double price)
{
if(HasPrevSwing)
{
const string name = LegPrefix + (string)(++LegSerial);
if(ObjectCreate(0, name, OBJ_TREND, 0, PrevSwingTime, PrevSwingPrice, t, price))
{
ObjectSetInteger(0, name, OBJPROP_COLOR, InpLegColor);
ObjectSetInteger(0, name, OBJPROP_STYLE, InpLegStyle);
ObjectSetInteger(0, name, OBJPROP_WIDTH, InpLegWidth);
ObjectSetInteger(0, name, OBJPROP_RAY_RIGHT, false);
ObjectSetInteger(0, name, OBJPROP_RAY_LEFT, false);
ObjectSetInteger(0, name, OBJPROP_BACK, true);
ObjectSetInteger(0, name, OBJPROP_SELECTABLE, false);
ObjectSetInteger(0, name, OBJPROP_HIDDEN, true);
}
}
HasPrevSwing = true;
PrevSwingTime = t;
PrevSwingPrice = price;
}
//+------------------------------------------------------------------+
void RaiseAlert(const SwingPoint &p, const datetime t)
{
if((!InpAlertPopup && !InpAlertPush) || t <= LastAlertTime)
return;
LastAlertTime = t;
const string text = StringFormat("%s %s: swing %s confirmed at %s",
_Symbol, EnumToString((ENUM_TIMEFRAMES)_Period),
p.is_high ? "HIGH" : "LOW",
DoubleToString(p.price, _Digits));
if(InpAlertPopup)
Alert(text);
if(InpAlertPush)
SendNotification(text);
}
//+------------------------------------------------------------------+
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[])
{
if(rates_total < 3)
return(0);
//--- first call, or history reloaded / shifted: rebuild from scratch
if(prev_calculated == 0)
{
ArrayInitialize(BufHigh, SMC_EMPTY);
ArrayInitialize(BufLow, SMC_EMPTY);
Detector.Configure(InpStartSide, InpBreakMode);
ResetLegs();
}
SwingPoint confirmed[];
Detector.Update(rates_total, high, low, close, confirmed);
const int count = ArraySize(confirmed);
for(int i = 0; i < count; i++)
{
const int idx = confirmed[i].index;
if(confirmed[i].is_high)
BufHigh[idx] = confirmed[i].price;
else
BufLow[idx] = confirmed[i].price;
if(InpShowLegs)
DrawLeg(time[idx], confirmed[i].price);
}
if(count > 0)
{
if(prev_calculated > 0) // never alert while loading history
RaiseAlert(confirmed[count - 1], time[confirmed[count - 1].index]);
}
return(rates_total);
}
//+------------------------------------------------------------------+
