Swing Extremes and Pullbacks in MQL5 (Part 3): Defining Structural Validity Beyond Simple Highs/Lows
Table of Contents
Introduction
For many traders, the concept of market structure is often reduced to a simplistic search for higher highs and lower lows. While visually intuitive, this approach suffers from a critical flaw: it treats every price extreme as equally significant, leading to entries based on "noise" rather than genuine shifts in market control. This lack of filtration often results in traders entering positions during consolidation phases or against fleeting liquidity grabs, mistaking minor retracements for trend reversals. Consequently, this 'structural ambiguity' increases false breakouts and worsens risk-to-reward setups. It also causes frustration when a well-defined support line is wicked out before the price reverses.
To solve this, we propose a multi-layered validation engine that moves beyond simple highs and lows by defining what makes a structure point valid. Instead of relying on raw swing points, the system uses a Structural Validation Engine. A swing is valid only if it shows a momentum-based Break of Structure (BoS), displacement via impulse candles, or results directly from a liquidity sweep. Combined with the Liquidity Interaction Layer and the State Machine, the algorithm distinguishes structural shifts from market noise. This methodology aligns entries with validated institutional zones rather than random price extremes. It targets liquidity pools while filtering out low-probability setups, resulting in a more robust, context-aware market structure.
System Overview and Understanding
The EA operates on a five-layer architecture designed to filter market noise and identify only structurally significant price levels. The process begins with Raw Swing Detection, where potential swing highs and lows are identified using an N-bar lookback. However, these are merely candidates—they are not yet considered valid structures. The Structural Validation Engine applies four criteria: momentum-based break of structure, displacement (impulse candle strength), liquidity sweep (wick beyond a prior level, then reverse), and time-based respect. A swing is only marked as "valid" if it satisfies at least one of these conditions, ensuring that the system ignores minor fluctuations and focuses only on levels where genuine market interest has been demonstrated.

Only swings that demonstrate Break of Structure, Displacement, Liquidity Sweep, or Time-Based Respect become valid—filtering noise from genuine market structure.
Once validated swings are established, the EA introduces a Liquidity Interaction Layer to identify where institutional orders may be resting. This layer marks zones such as equal highs/lows and untouched swing points as liquidity pools, and tracks whether these zones have been taken. Simultaneously, a Structural State Machine continuously evaluates the relationship between consecutive valid swings to determine the current market phase—Accumulation, Expansion, Distribution, or Reversal. This state machine does not simply assign a bullish or bearish label; it monitors how price is interacting with validated levels and liquidity zones, allowing the system to anticipate whether the market is trending, consolidating, or preparing for a reversal. By combining validated structure with real-time liquidity tracking, the EA builds a high-probability context for trade decisions.

Liquidity zones mark where institutional orders reside, while the state machine continuously evaluates whether the market is accumulating, expanding, distributing, or reversing.
The Execution Engine then synthesizes these layers to determine entry. A buy signal is generated only when the system confirms a valid higher low, a liquidity sweep below price, or bullish displacement—and crucially, when the market state supports a long (Expansion or Accumulation). Similarly, a sell requires a valid lower high, liquidity sweep above, or bearish displacement, aligned with a Distribution or Reversal state. Stop losses are placed at the nearest validated structure level, not at raw swing genuine points. This anchors protection to turning points. Take profits are calculated based on a predefined Risk:Reward ratio, with the option to target the next liquidity zone or valid structure node. This multi-layer validation ensures that every trade is executed based on structural integrity rather than impulsive price movements, aligning entries with the underlying market narrative.

Entry requires confluence of validated structure, liquidity interaction, and market state alignment. Stop losses anchor to validated structure, not raw swings, with take profits targeting a defined Risk:Reward ratio.
Getting Started
//+------------------------------------------------------------------+ //| Structural_Validation.mq5 | //| GIT under Copyright 2025, MetaQuotes Ltd. | //| https://www.mql5.com/en/users/johnhlomohang/ | //+------------------------------------------------------------------+ #property copyright "GIT under Copyright 2025, MetaQuotes Ltd." #property link "https://www.mql5.com/en/users/johnhlomohang/" #property version "1.00" #include <Trade/Trade.mqh> CTrade trade; //+------------------------------------------------------------------+ //| Input Parameters | //+------------------------------------------------------------------+ input int SwingLookback = 5; // Bars for swing detection input double DisplacementFactor = 1.5; // Impulse strength factor input int StructureHoldBars = 3; // Bars structure must hold input int ATR_Period = 14; // ATR calculation period input double RiskPercent = 1.0; // Risk per trade (%) input int StopLossPoints = 2000; // Stop Loss in points input double RiskRewardRatio = 2.0; // Risk:Reward ratio input bool Visualize = true; // Show visualization //+------------------------------------------------------------------+ //| Enumerations | //+------------------------------------------------------------------+ enum MarketState { ACCUMULATION, EXPANSION, DISTRIBUTION, REVERSAL };
To get started, import and instantiate CTrade to handle order execution and position management. We then define a set of input parameters that allow us to control the behavior of the system dynamically without modifying the code. These inputs govern how we detect swing points (SwingLookback), evaluate market strength through displacement (DisplacementFactor), enforce structural stability over time (StructureHoldBars), and measure volatility using ATR (ATR_Period). In addition, we specify key risk management parameters such as the percentage of capital to risk per trade, stop-loss distance, and the desired risk-to-reward ratio, while also enabling an optional visualization mode to display structural elements directly on the chart.
To further organize our logic, we introduce a MarketState enumeration that allows us to classify the market into distinct phases: accumulation, expansion, distribution, and reversal. By doing this, we move beyond simple price-based decisions and instead frame our strategy around how the market behaves structurally over time. This classification becomes essential later in the system, as it helps us align trade decisions with the current market context, ensuring that entries are not only technically valid but also consistent with the broader phase of price action.
//+------------------------------------------------------------------+ //| Structures | //+------------------------------------------------------------------+ struct SwingPoint { datetime time; double price; bool isHigh; bool isValid; bool isUsed; int barIndex; double candleSize; void Reset() { time = 0; price = 0.0; isHigh = true; isValid = false; isUsed = false; barIndex = -1; candleSize = 0.0; } }; struct LiquidityZone { double price; datetime time; bool taken; bool isHigh; // true = high liquidity, false = low liquidity string type; // "equal", "session", "untouched" void Reset() { price = 0.0; time = 0; taken = false; isHigh = true; type = ""; } }; struct MarketStructure { double lastHigh; double lastLow; datetime lastHighTime; datetime lastLowTime; bool bullish; MarketState state; void Reset() { lastHigh = 0.0; lastLow = 0.0; lastHighTime = 0; lastLowTime = 0; bullish = true; state = ACCUMULATION; } };
We now formalize how the EA understands price by defining structured data models for swings, liquidity, and overall market behavior. The SwingPoint structure represents each detected turning point in the market, capturing not just its price and time, but also whether it is a high or low, whether it has been validated as a meaningful structure, and whether it has already been used in decision-making. By including attributes such as barIndex and candleSize, we give ourselves the ability to link each swing back to its originating candle and evaluate its strength. The Reset() function ensures that every swing instance can be cleanly reinitialized, which is especially useful when recalculating structure dynamically as new market data comes in.
In parallel, we introduce LiquidityZone and MarketStructure to elevate our system beyond individual price points into contextual understanding. The LiquidityZone structure allows us to track areas where orders are likely clustered, distinguishing between different types such as equal highs/lows or untouched levels, while also monitoring whether that liquidity has already been taken. The MarketStructure structure then ties everything together by maintaining the most recent significant highs and lows, tracking whether the market is currently bullish or bearish, and assigning a broader state such as accumulation or expansion.
//+------------------------------------------------------------------+ //| Global Variables | //+------------------------------------------------------------------+ SwingPoint swingCandidates[]; SwingPoint validSwings[]; LiquidityZone liquidityZones[]; MarketStructure marketStruct; int atrHandle; datetime lastBarTime = 0; datetime lastSwingDetection = 0; double avgCandleSize = 0; //--- Trade tracking datetime lastTradeTime = 0; double lastTradePrice = 0; int lastTradeType = 0; // 1 = buy, -1 = sell double BID = SymbolInfoDouble(_Symbol,SYMBOL_BID); double ASK = SymbolInfoDouble(_Symbol, SYMBOL_ASK); //+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit() { //--- Initialize arrays ArrayResize(swingCandidates, 0); ArrayResize(validSwings, 0); ArrayResize(liquidityZones, 0); //--- Initialize market structure marketStruct.Reset(); //--- Initialize ATR handle atrHandle = iATR(_Symbol, PERIOD_CURRENT, ATR_Period); if(atrHandle == INVALID_HANDLE) { Print("Failed to create ATR handle"); return INIT_FAILED; } //--- Initial detection DetectSwingCandidates(); ValidateSwings(); UpdateLiquidityZones(); UpdateMarketState(); return INIT_SUCCEEDED; } //+------------------------------------------------------------------+ //| Expert deinitialization function | //+------------------------------------------------------------------+ void OnDeinit(const int reason) { if(atrHandle != INVALID_HANDLE) IndicatorRelease(atrHandle); if(Visualize) { ObjectsDeleteAll(0, "SF_"); ChartRedraw(); } }
At this stage, we define the global variables used to track structure, liquidity, and trading activity. Arrays such as swingCandidates, validSwings, and liquidityZones act as dynamic containers that evolve with incoming price data, allowing us to separate raw swing detection from structurally validated points and areas of interest in the market. The marketStruct variable maintains the current structural context, while additional variables like atrHandle, avgCandleSize, and lastBarTime help us measure volatility, maintain timing consistency, and ensure calculations are only performed on new bars. We also include trade-tracking variables such as lastTradeTime, lastTradePrice, and lastTradeType, which give us control over execution frequency and allow the system to make more informed decisions based on recent activity, alongside real-time BID and ASK price references.
The OnInit() function serves as the initialization phase where we prepare all components before the EA begins operation. Here, we reset and resize arrays to ensure a clean state, initialize the market structure, and create an ATR indicator handle that will later be used for volatility-based calculations. We also perform an initial pass of swing detection, validation, liquidity mapping, and market state classification so that the EA starts with a fully informed view of the chart rather than waiting for the first tick. Conversely, the OnDeinit() function ensures proper cleanup when the EA is removed, releasing the ATR resource and clearing any chart objects if visualization is enabled.
//+------------------------------------------------------------------+ //| Expert tick function | //+------------------------------------------------------------------+ void OnTick() { datetime currentBarTime = iTime(_Symbol, PERIOD_CURRENT, 0); //--- Process on new bar if(currentBarTime != lastBarTime) { lastBarTime = currentBarTime; //--- Update average candle size UpdateAvgCandleSize(); //--- Layer 1: Detect new swing candidates DetectSwingCandidates(); //--- Layer 2: Validate swings ValidateSwings(); //--- Layer 3: Update liquidity zones UpdateLiquidityZones(); //--- Layer 4: Update market state UpdateMarketState(); //--- Layer 5: Check and execute trades CheckTradeConditions(); //--- Visualization if(Visualize) UpdateVisualization(); } } //+------------------------------------------------------------------+ //| LAYER 1: Raw Swing Detection | //+------------------------------------------------------------------+ void DetectSwingCandidates() { MqlRates rates[]; ArraySetAsSeries(rates, true); int copied = CopyRates(_Symbol, PERIOD_CURRENT, 0, 100, rates); if(copied < SwingLookback * 2 + 1) return; //--- Clear old candidates (keep only last 50 bars) int newCount = 0; SwingPoint tempCandidates[]; ArrayResize(tempCandidates, 0); for(int i = SwingLookback; i < copied - SwingLookback; i++) { bool isSwingHigh = true; bool isSwingLow = true; //--- Check swing high for(int j = 1; j <= SwingLookback; j++) { if(rates[i].high <= rates[i-j].high || rates[i].high <= rates[i+j].high) isSwingHigh = false; if(rates[i].low >= rates[i-j].low || rates[i].low >= rates[i+j].low) isSwingLow = false; } //--- Add swing high candidate if(isSwingHigh) { SwingPoint sp; sp.Reset(); sp.time = rates[i].time; sp.price = rates[i].high; sp.isHigh = true; sp.barIndex = i; sp.candleSize = (rates[i].high - rates[i].low) / _Point; ArrayResize(tempCandidates, newCount + 1); tempCandidates[newCount] = sp; newCount++; } //--- Add swing low candidate if(isSwingLow) { SwingPoint sp; sp.Reset(); sp.time = rates[i].time; sp.price = rates[i].low; sp.isHigh = false; sp.barIndex = i; sp.candleSize = (rates[i].high - rates[i].low) / _Point; ArrayResize(tempCandidates, newCount + 1); tempCandidates[newCount] = sp; newCount++; } } //--- Update global candidates array ArrayResize(swingCandidates, newCount); for(int i = 0; i < newCount; i++) swingCandidates[i] = tempCandidates[i]; }
In OnTick(), the EA processes only on a new bar rather than on every tick. We achieve this by comparing the current bar time with the previously stored lastBarTime, ensuring that all calculations are performed once per bar for consistency and stability. Once a new bar is detected, we execute a structured pipeline: first updating the average candle size to reflect current volatility, then progressing through each logical layer of the system—detecting swing candidates, validating them, identifying liquidity zones, updating the overall market state, and finally evaluating trade opportunities. If visualization is enabled, we conclude by updating chart objects, allowing us to visually track how the system interprets market structure in real time.
The DetectSwingCandidates() function represents the first layer of this pipeline, where we identify potential turning points in price without yet assigning them structural significance. We retrieve recent market data using CopyRates and iterate through it while applying a lookback window to determine whether a candle qualifies as a swing high or swing low. A swing high is confirmed when its high exceeds those of neighboring candles, while a swing low is identified when its low is lower than surrounding candles. For each detected candidate, we create a SwingPoint, populate it with relevant information such as time, price, position, and candle size, and store it temporarily before updating the global array. At this stage, these points are purely candidates—they represent possible structure, which will later be filtered and validated in subsequent layers of the system.
//+------------------------------------------------------------------+ //| LAYER 2: Structural Validation Engine | //+------------------------------------------------------------------+ void ValidateSwings() { MqlRates rates[]; ArraySetAsSeries(rates, true); CopyRates(_Symbol, PERIOD_CURRENT, 0, 50, rates); if(ArraySize(rates) < StructureHoldBars + 1) return; //--- Reset validation for all candidates for(int i = 0; i < ArraySize(swingCandidates); i++) swingCandidates[i].isValid = false; //--- Clear valid swings array ArrayResize(validSwings, 0); int validCount = 0; for(int i = 0; i < ArraySize(swingCandidates); i++) { bool isValid = false; //--- Validation A: Break of Structure (BoS) if(CheckBreakOfStructure(swingCandidates[i], rates)) isValid = true; //--- Validation B: Displacement (Impulse Strength) if(CheckDisplacement(swingCandidates[i], rates)) isValid = true; //--- Validation C: Liquidity Sweep if(CheckLiquiditySweep(swingCandidates[i], rates)) isValid = true; //--- Validation D: Time-Based Respect if(CheckTimeRespect(swingCandidates[i], rates)) isValid = true; if(isValid) { swingCandidates[i].isValid = true; //--- Add to valid swings ArrayResize(validSwings, validCount + 1); validSwings[validCount] = swingCandidates[i]; validCount++; } } } //+------------------------------------------------------------------+ //| Validation A: Break of Structure | //+------------------------------------------------------------------+ bool CheckBreakOfStructure(SwingPoint &sp, MqlRates &rates[]) { if(sp.isHigh) { //--- Find previous valid high double prevHigh = 0; for(int i = 0; i < ArraySize(validSwings); i++) { if(validSwings[i].isHigh && validSwings[i].time < sp.time) { if(prevHigh == 0 || validSwings[i].price > prevHigh) prevHigh = validSwings[i].price; } } } else { //--- Find previous valid low double prevLow = 0; for(int i = 0; i < ArraySize(validSwings); i++) { if(!validSwings[i].isHigh && validSwings[i].time < sp.time) { if(prevLow == 0 || validSwings[i].price < prevLow) prevLow = validSwings[i].price; } } } return false; } //+------------------------------------------------------------------+ //| Validation B: Displacement (Impulse Strength) | //+------------------------------------------------------------------+ bool CheckDisplacement(SwingPoint &sp, MqlRates &rates[]) { if(avgCandleSize == 0) return false; double impulseSize = sp.candleSize; return (impulseSize > avgCandleSize * DisplacementFactor); }
The ValidateSwings() function represents the core of our structural intelligence, where we transform raw swing candidates into meaningful market structure. Instead of treating every detected high or low as important, we systematically evaluate each candidate against a set of validation conditions that reflect real market behavior. We begin by resetting all candidates to an unvalidated state and clearing the validSwings array to ensure a fresh evaluation on each pass. Then, for every swing candidate, we test whether it satisfies at least one of several criteria: a break of structure, strong displacement, a liquidity sweep, or time-based respect. If any of these conditions are met, the swing is marked as valid and added to the validSwings array, effectively filtering out noise and retaining only those points that demonstrate actual market intent.
The validation functions themselves define what "meaningful" structure looks like in practice. In CheckBreakOfStructure(), we attempt to identify whether the current swing participates in breaking a previous structural level by first locating the most relevant prior high or low; although the logic is partially implemented here, it sets the foundation for confirming directional shifts in price. In contrast, CheckDisplacement() focuses on momentum, comparing the size of the swing’s originating candle to the average candle size to determine whether the move was impulsive rather than random.
//+------------------------------------------------------------------+ //| Validation C: Liquidity Sweep | //+------------------------------------------------------------------+ bool CheckLiquiditySweep(SwingPoint &sp, MqlRates &rates[]) { int barIndex = FindBarIndexByTime(sp.time); if(barIndex < 0 || barIndex >= ArraySize(rates)) return false; if(sp.isHigh) { //--- Check if price wicked above previous high and closed below for(int i = 0; i < ArraySize(validSwings); i++) { if(validSwings[i].isHigh && validSwings[i].time < sp.time) { double prevHigh = validSwings[i].price; if(rates[barIndex].high > prevHigh && rates[barIndex].close < prevHigh) return true; } } } else { //--- Check if price wicked below previous low and closed above for(int i = 0; i < ArraySize(validSwings); i++) { if(!validSwings[i].isHigh && validSwings[i].time < sp.time) { double prevLow = validSwings[i].price; if(rates[barIndex].low < prevLow && rates[barIndex].close > prevLow) return true; } } } return false; } //+------------------------------------------------------------------+ //| Validation D: Time-Based Respect | //+------------------------------------------------------------------+ bool CheckTimeRespect(SwingPoint &sp, MqlRates &rates[]) { int barIndex = FindBarIndexByTime(sp.time); if(barIndex < 0 || barIndex + StructureHoldBars >= ArraySize(rates)) return false; if(sp.isHigh) { //--- Structure must hold for X bars (price stays below swing high) for(int i = 1; i <= StructureHoldBars; i++) { if(rates[barIndex + i].high > sp.price) return false; } return true; } else { //--- Structure must hold for X bars (price stays above swing low) for(int i = 1; i <= StructureHoldBars; i++) { if(rates[barIndex + i].low < sp.price) return false; } return true; } }
The CheckLiquiditySweep() function allows us to validate structure based on how price interacts with liquidity, rather than just where it turns. We begin by locating the exact bar associated with the swing point, ensuring we are evaluating the correct candle in context. From there, we check whether price temporarily moves beyond a previous structural level—such as a prior high or low—but fails to hold that position. For a swing high, this means price wicks above an earlier high and then closes back below it, signaling a rejection after taking liquidity. The same logic applies in reverse for swing lows. This behavior is critical because it reflects stop-hunting or liquidity grabbing, which often precedes strong directional moves, making such swings far more meaningful than simple turning points.
The CheckTimeRespect() function complements this by introducing a stability requirement, ensuring that a swing level is respected by the market over a defined number of bars. After identifying the bar index of the swing, we verify that price does not violate the swing level for a specified duration (StructureHoldBars). For a swing high, price must remain below that level, and for a swing low, it must remain above. If this condition holds, the swing is considered structurally respected, indicating that the level has genuine significance in guiding price action.
//+------------------------------------------------------------------+ //| LAYER 3: Liquidity Interaction Layer | //+------------------------------------------------------------------+ void UpdateLiquidityZones() { MqlRates rates[]; ArraySetAsSeries(rates, true); CopyRates(_Symbol, PERIOD_CURRENT, 0, 100, rates); if(ArraySize(rates) < 50) return; //--- Clear old liquidity zones ArrayResize(liquidityZones, 0); int zoneCount = 0; //--- Detect equal highs/lows (double tops/bottoms) for(int i = 0; i < ArraySize(validSwings) - 1; i++) { for(int j = i + 1; j < ArraySize(validSwings); j++) { double priceDiff = MathAbs(validSwings[i].price - validSwings[j].price); if(priceDiff < _Point * 10 && validSwings[i].isHigh == validSwings[j].isHigh) { LiquidityZone zone; zone.Reset(); zone.price = validSwings[i].price; zone.time = validSwings[i].time; zone.isHigh = validSwings[i].isHigh; zone.type = "equal"; zone.taken = CheckIfLiquidityTaken(zone, rates); ArrayResize(liquidityZones, zoneCount + 1); liquidityZones[zoneCount] = zone; zoneCount++; break; } } } //--- Add untouched swing points as liquidity for(int i = 0; i < ArraySize(validSwings); i++) { if(!validSwings[i].isUsed) { LiquidityZone zone; zone.Reset(); zone.price = validSwings[i].price; zone.time = validSwings[i].time; zone.isHigh = validSwings[i].isHigh; zone.type = "untouched"; zone.taken = CheckIfLiquidityTaken(zone, rates); ArrayResize(liquidityZones, zoneCount + 1); liquidityZones[zoneCount] = zone; zoneCount++; } } } //+------------------------------------------------------------------+ //| Check if liquidity zone has been taken | //+------------------------------------------------------------------+ bool CheckIfLiquidityTaken(LiquidityZone &zone, MqlRates &rates[]) { for(int i = 0; i < ArraySize(rates); i++) { if(zone.isHigh && rates[i].high > zone.price) return true; if(!zone.isHigh && rates[i].low < zone.price) return true; } return false; } //+------------------------------------------------------------------+ //| LAYER 4: Structural State Machine | //+------------------------------------------------------------------+ void UpdateMarketState() { if(ArraySize(validSwings) < 2) return; //--- Get last two valid swings SwingPoint lastSwing = validSwings[ArraySize(validSwings) - 1]; SwingPoint prevSwing = validSwings[ArraySize(validSwings) - 2]; //--- Update last high/low if(lastSwing.isHigh && lastSwing.price > marketStruct.lastHigh) { marketStruct.lastHigh = lastSwing.price; marketStruct.lastHighTime = lastSwing.time; } if(!lastSwing.isHigh && (marketStruct.lastLow == 0 || lastSwing.price < marketStruct.lastLow)) { marketStruct.lastLow = lastSwing.price; marketStruct.lastLowTime = lastSwing.time; } //--- Determine trend based on higher highs and higher lows bool higherHigh = false; bool higherLow = false; for(int i = 0; i < ArraySize(validSwings); i++) { if(validSwings[i].isHigh && validSwings[i].price > marketStruct.lastHigh) higherHigh = true; if(!validSwings[i].isHigh && validSwings[i].price > marketStruct.lastLow) higherLow = true; } marketStruct.bullish = (higherHigh && higherLow); //--- State transition logic MarketState prevState = marketStruct.state; //--- Check for liquidity sweep failure (potential reversal) bool liquiditySweepFailed = CheckLiquiditySweepFailure(); switch(marketStruct.state) { case ACCUMULATION: if(marketStruct.bullish) marketStruct.state = EXPANSION; else if(!marketStruct.bullish && ArraySize(validSwings) > 5) marketStruct.state = DISTRIBUTION; break; case EXPANSION: if(!marketStruct.bullish) marketStruct.state = DISTRIBUTION; else if(liquiditySweepFailed) marketStruct.state = REVERSAL; break; case DISTRIBUTION: if(marketStruct.bullish) marketStruct.state = ACCUMULATION; else if(liquiditySweepFailed) marketStruct.state = REVERSAL; break; case REVERSAL: if(marketStruct.bullish && prevState == EXPANSION) marketStruct.state = ACCUMULATION; else if(!marketStruct.bullish && prevState == DISTRIBUTION) marketStruct.state = ACCUMULATION; break; } }
In this layer, we shift from individual structural points to identifying where liquidity is likely resting in the market. The UpdateLiquidityZones() function builds a dynamic map of these areas by scanning through validated swings and grouping them into meaningful zones. First, we detect equal highs and lows—commonly associated with double tops or bottoms—by checking whether two swing points occur at nearly the same price level. These are marked as "equal" liquidity zones, as they tend to attract stop orders. We then extend this logic by treating all unused valid swings as "untouched" liquidity, representing levels the market has not yet revisited. Each zone is evaluated using CheckIfLiquidityTaken(), which determines whether price has already traded through the level, allowing us to distinguish between active and consumed liquidity. This gives the system a forward-looking perspective on where price may be drawn next.
Building on this, the UpdateMarketState() function introduces a structural state machine that interprets how the market is evolving. We begin by updating the most recent significant high and low, then assess whether the market is forming higher highs and higher lows to determine directional bias. This information feeds into a state transition system that classifies the market into phases such as accumulation, expansion, distribution, or reversal. By incorporating conditions like liquidity sweep failures, we can detect potential turning points where the market’s intent shifts. This layered approach allows us to move beyond static analysis and instead model the market as a sequence of evolving states, ensuring that our trading decisions align with both structure and underlying liquidity behavior.
//+------------------------------------------------------------------+ //| Check liquidity sweep failure | //+------------------------------------------------------------------+ bool CheckLiquiditySweepFailure() { MqlRates rates[]; ArraySetAsSeries(rates, true); CopyRates(_Symbol, PERIOD_CURRENT, 0, 10, rates); if(ArraySize(rates) < 5) return false; //--- Check if price swept a high and reversed for(int i = 0; i < ArraySize(liquidityZones); i++) { if(liquidityZones[i].isHigh && !liquidityZones[i].taken) { if(rates[0].high > liquidityZones[i].price && rates[0].close < liquidityZones[i].price) return true; } if(!liquidityZones[i].isHigh && !liquidityZones[i].taken) { if(rates[0].low < liquidityZones[i].price && rates[0].close > liquidityZones[i].price) return true; } } return false; } //+------------------------------------------------------------------+ //| LAYER 5: Execution Engine | //+------------------------------------------------------------------+ void CheckTradeConditions() { //--- Check if already in a position if(PositionSelect(_Symbol)) { //--- Check if we should trail stop or manage position ManageOpenPosition(); return; } //--- Avoid trading too frequently if(TimeCurrent() - lastTradeTime < PeriodSeconds(PERIOD_CURRENT) * 3) return; MqlRates current[]; ArraySetAsSeries(current, true); CopyRates(_Symbol, PERIOD_CURRENT, 0, 3, current); if(ArraySize(current) < 2) return; //--- Get last validated swing if(ArraySize(validSwings) == 0) return; SwingPoint lastValidSwing = validSwings[ArraySize(validSwings) - 1]; //--- Buy conditions if(CheckBuyConditions(lastValidSwing, current)) { ExecuteTrade(ORDER_TYPE_BUY); lastTradeTime = TimeCurrent(); lastTradeType = 1; } //--- Sell conditions else if(CheckSellConditions(lastValidSwing, current)) { ExecuteTrade(ORDER_TYPE_SELL); lastTradeTime = TimeCurrent(); lastTradeType = -1; } }
The CheckLiquiditySweepFailure() function is designed to detect a very specific and powerful market behavior: when price attempts to take liquidity but fails to continue in that direction, often signaling a potential reversal. We begin by collecting a small window of recent price data and ensuring there is enough information to evaluate. Then, for each active (untaken) liquidity zone, we check whether the most recent candle has moved beyond that level—indicating a sweep—but closed back inside, showing rejection. For highs, this means price breaks above the zone and closes below it; for lows, the opposite applies. This failure to sustain the breakout suggests that the move was not genuine, but rather a liquidity grab, which can be a strong precursor to a shift in direction.
CheckTradeConditions() is the execution gate that combines the structure and liquidity layers. We first ensure that no position is currently open, delegating management to a separate function if one exists, and enforce a time-based filter to avoid overtrading. After retrieving recent price data, we focus on the most recent validated swing as our structural reference point. From there, we evaluate both buy and sell conditions using dedicated functions that incorporate structural validity, liquidity interaction, and market state. If the criteria for either direction are met, we execute the corresponding trade and update tracking variables to maintain control over trade frequency and direction. This ensures that entries are not random, but instead aligned with the broader structural narrative defined by the system.
//+------------------------------------------------------------------+ //| Check Buy Conditions | //+------------------------------------------------------------------+ bool CheckBuyConditions(SwingPoint &lastSwing, MqlRates &rates[]) { //--- Must be a valid low if(lastSwing.isHigh || !lastSwing.isValid) return false; //--- Condition 1: Valid higher low double previousLow = 0; for(int i = 0; i < ArraySize(validSwings) - 1; i++) { if(!validSwings[i].isHigh && validSwings[i].price < lastSwing.price) { if(previousLow == 0 || validSwings[i].price > previousLow) previousLow = validSwings[i].price; } } bool higherLow = (previousLow > 0 && lastSwing.price > previousLow); //--- Condition 2: Liquidity sweep below bool liquiditySweep = false; for(int i = 0; i < ArraySize(liquidityZones); i++) { if(!liquidityZones[i].isHigh && !liquidityZones[i].taken) { if(rates[0].low < liquidityZones[i].price && rates[0].close > liquidityZones[i].price) { liquiditySweep = true; break; } } } //--- Condition 3: Bullish displacement bool bullishDisplacement = false; if(ArraySize(rates) >= 2) { double candleSize = (rates[0].close - rates[0].open) / _Point; if(candleSize > avgCandleSize * DisplacementFactor) bullishDisplacement = true; } //--- Condition 4: Market state should be bullish or accumulation bool goodState = (marketStruct.state == EXPANSION || marketStruct.state == ACCUMULATION); return (higherLow || liquiditySweep || bullishDisplacement) && goodState; } //+------------------------------------------------------------------+ //| Check Sell Conditions | //+------------------------------------------------------------------+ bool CheckSellConditions(SwingPoint &lastSwing, MqlRates &rates[]) { //--- Must be a valid high if(!lastSwing.isHigh || !lastSwing.isValid) return false; //--- Condition 1: Valid lower high double previousHigh = 0; for(int i = 0; i < ArraySize(validSwings) - 1; i++) { if(validSwings[i].isHigh && validSwings[i].price > lastSwing.price) { if(previousHigh == 0 || validSwings[i].price < previousHigh) previousHigh = validSwings[i].price; } } bool lowerHigh = (previousHigh > 0 && lastSwing.price < previousHigh); //--- Condition 2: Liquidity sweep above bool liquiditySweep = false; for(int i = 0; i < ArraySize(liquidityZones); i++) { if(liquidityZones[i].isHigh && !liquidityZones[i].taken) { if(rates[0].high > liquidityZones[i].price && rates[0].close < liquidityZones[i].price) { liquiditySweep = true; break; } } } //--- Condition 3: Bearish displacement bool bearishDisplacement = false; if(ArraySize(rates) >= 2) { double candleSize = (rates[0].open - rates[0].close) / _Point; if(candleSize > avgCandleSize * DisplacementFactor) bearishDisplacement = true; } //--- Condition 4: Market state should be bearish or distribution bool goodState = (marketStruct.state == DISTRIBUTION || marketStruct.state == REVERSAL); return (lowerHigh || liquiditySweep || bearishDisplacement) && goodState; } //+------------------------------------------------------------------+ //| Execute Trade | //+------------------------------------------------------------------+ void ExecuteTrade(ENUM_ORDER_TYPE tradeType) { double price = (tradeType == ORDER_TYPE_BUY) ? SymbolInfoDouble(_Symbol, SYMBOL_ASK) : SymbolInfoDouble(_Symbol, SYMBOL_BID); //--- Calculate stop loss based on validated structure double sl = CalculateStopLoss(tradeType); if(sl == 0) return; //--- Calculate take profit based on risk:reward double riskPoints = MathAbs(price - sl) / _Point; double tpPoints = riskPoints * RiskRewardRatio; double tp = (tradeType == ORDER_TYPE_BUY) ? price + tpPoints * _Point : price - tpPoints * _Point; //--- Normalize prices price = NormalizeDouble(price, _Digits); sl = NormalizeDouble(sl, _Digits); tp = NormalizeDouble(tp, _Digits); //--- Calculate lot size double volume = CalculateLotSize(price, sl); //--- Execute trade string comment = StringFormat("SF_%s", (tradeType == ORDER_TYPE_BUY) ? "BUY" : "SELL"); bool success = trade.PositionOpen(_Symbol, tradeType, volume, price, sl, tp, comment); if(success) { lastTradePrice = price; Print(StringFormat("Trade Opened: %s | Price: %.5f | SL: %.5f | TP: %.5f | Lots: %.2f", (tradeType == ORDER_TYPE_BUY) ? "BUY" : "SELL", price, sl, tp, volume)); //--- Mark used swings for(int i = 0; i < ArraySize(validSwings); i++) { if(MathAbs(validSwings[i].price - price) < _Point * 10) validSwings[i].isUsed = true; } } else { Print("Trade failed: ", trade.ResultRetcodeDescription()); } }
The CheckBuyConditions() and CheckSellConditions() functions define how we translate structural understanding into actionable trade signals. For buys, we require the most recent swing to be a valid low, ensuring we are working from a structurally meaningful base. We then evaluate three core confirmations: whether the market is forming a higher low (indicating bullish structure), whether liquidity below has been swept and rejected (suggesting accumulation and intent to move higher), and whether there is strong bullish displacement in the most recent candle. These conditions are not required simultaneously; instead, we allow flexibility by accepting any of them as long as the broader market state supports bullish behavior, such as during accumulation or expansion phases. The sell logic mirrors this approach in reverse, focusing on valid highs, lower highs, liquidity sweeps above, and bearish displacement, while aligning with distribution or reversal states.
The ExecuteTrade() function then operationalizes these signals by handling order placement with proper risk management. We begin by determining the correct entry price based on trade direction and calculating a stop loss anchored to validated structural levels rather than arbitrary distances. From this, we derive the take profit using a predefined risk-to-reward ratio, ensuring consistency in trade expectancy. Prices are normalized to match symbol precision, and position size is calculated dynamically based on the defined risk percentage and stop distance. Once the trade is executed, we log the details for transparency and mark nearby validated swings as "used" to prevent repeated reliance on the same structure. This ensures that each trade is both context-aware and systematically managed, reinforcing the integrity of the overall strategy.
//+------------------------------------------------------------------+ //| Calculate Stop Loss Based on Validated Structure | //+------------------------------------------------------------------+ double CalculateStopLoss(ENUM_ORDER_TYPE tradeType) { if(ArraySize(validSwings) < 1) return 0; if(tradeType == ORDER_TYPE_BUY) { //--- Find nearest valid low below current price double currentPrice = SymbolInfoDouble(_Symbol, SYMBOL_BID); double nearestLow = 0; for(int i = 0; i < ArraySize(validSwings); i++) { if(!validSwings[i].isHigh && validSwings[i].price < currentPrice) { if(nearestLow == 0 || validSwings[i].price > nearestLow) nearestLow = validSwings[i].price; } } if(nearestLow > 0) { double buffer = _Point * 10; return nearestLow - buffer; } } else { //--- Find nearest valid high above current price double currentPrice = SymbolInfoDouble(_Symbol, SYMBOL_ASK); double nearestHigh = 0; for(int i = 0; i < ArraySize(validSwings); i++) { if(validSwings[i].isHigh && validSwings[i].price > currentPrice) { if(nearestHigh == 0 || validSwings[i].price < nearestHigh) nearestHigh = validSwings[i].price; } } if(nearestHigh > 0) { double buffer = _Point * 10; return nearestHigh + buffer; } } //--- Fallback to fixed stop loss double point = SymbolInfoDouble(_Symbol, SYMBOL_POINT); double currentPrice = (tradeType == ORDER_TYPE_BUY) ? SymbolInfoDouble(_Symbol, SYMBOL_BID) : SymbolInfoDouble(_Symbol, SYMBOL_ASK); if(tradeType == ORDER_TYPE_BUY) return currentPrice - (StopLossPoints * point); else return currentPrice + (StopLossPoints * point); }
The CalculateStopLoss() function determines a structurally meaningful stop loss by anchoring it to the nearest validated swing rather than using arbitrary distances. For buy trades, we scan through validated swings to find the closest swing low below the current price, while for sell trades, we locate the nearest swing high above the current price. Once identified, a small buffer is added beyond that level to account for minor price fluctuations and avoid premature stop-outs. If no suitable structural level is found, the function falls back to a fixed stop loss based on predefined points, ensuring that every trade still maintains a risk boundary even in the absence of clear structure.
//+------------------------------------------------------------------+ //| Helper Functions | //+------------------------------------------------------------------+ void UpdateAvgCandleSize() { MqlRates rates[]; ArraySetAsSeries(rates, true); int copied = CopyRates(_Symbol, PERIOD_CURRENT, 0, 50, rates); if(copied < 20) return; double totalSize = 0; for(int i = 0; i < copied; i++) totalSize += (rates[i].high - rates[i].low) / _Point; avgCandleSize = totalSize / copied; } //+------------------------------------------------------------------+ //| Find Bar Index by Time | //+------------------------------------------------------------------+ int FindBarIndexByTime(datetime targetTime) { MqlRates rates[]; ArraySetAsSeries(rates, true); int copied = CopyRates(_Symbol, PERIOD_CURRENT, 0, 100, rates); for(int i = 0; i < copied; i++) { if(rates[i].time == targetTime) return i; } return -1; } //+------------------------------------------------------------------+ //| Visualization Functions | //+------------------------------------------------------------------+ void UpdateVisualization() { ObjectsDeleteAll(0, "SF_"); //--- Draw valid swings (green for highs, red for lows) for(int i = 0; i < ArraySize(validSwings); i++) { color clr = validSwings[i].isHigh ? clrGreen : clrRed; string prefix = validSwings[i].isHigh ? "ValidHigh" : "ValidLow"; string name = StringFormat("SF_%s_%d", prefix, validSwings[i].time); ObjectCreate(0, name, OBJ_ARROW, 0, validSwings[i].time, validSwings[i].price); ObjectSetInteger(0, name, OBJPROP_ARROWCODE, validSwings[i].isHigh ? 218 : 217); ObjectSetInteger(0, name, OBJPROP_COLOR, clr); ObjectSetInteger(0, name, OBJPROP_FONTSIZE, 10); } //--- Draw invalid swings (gray) for(int i = 0; i < ArraySize(swingCandidates); i++) { if(!swingCandidates[i].isValid) { string name = StringFormat("SF_Invalid_%d", swingCandidates[i].time); ObjectCreate(0, name, OBJ_ARROW, 0, swingCandidates[i].time, swingCandidates[i].price); ObjectSetInteger(0, name, OBJPROP_ARROWCODE, swingCandidates[i].isHigh ? 218 : 217); ObjectSetInteger(0, name, OBJPROP_COLOR, clrGray); ObjectSetInteger(0, name, OBJPROP_FONTSIZE, 10); } } //--- Draw liquidity zones as rectangles for(int i = 0; i < ArraySize(liquidityZones); i++) { if(!liquidityZones[i].taken) { string name = StringFormat("SF_Liq_%d", i); datetime timeNow = TimeCurrent(); datetime timeStart = timeNow - PeriodSeconds(PERIOD_CURRENT) * 10; datetime timeEnd = timeNow; color zoneClr = liquidityZones[i].isHigh ? clrOrange : clrLightBlue; ObjectCreate(0, name, OBJ_RECTANGLE, 0, timeStart, liquidityZones[i].price + _Point * 5, timeEnd, liquidityZones[i].price - _Point * 5); ObjectSetInteger(0, name, OBJPROP_COLOR, zoneClr); ObjectSetInteger(0, name, OBJPROP_FILL, true); ObjectSetInteger(0, name, OBJPROP_WIDTH, 1); } } //--- Draw market state label string stateText = "Market State: "; switch(marketStruct.state) { case ACCUMULATION: stateText += "ACCUMULATION"; break; case EXPANSION: stateText += "EXPANSION (BULLISH)"; break; case DISTRIBUTION: stateText += "DISTRIBUTION"; break; case REVERSAL: stateText += "REVERSAL"; break; } CreateLabel("State", stateText, 10, 20, clrWhite); CreateLabel("Trend", StringFormat("Trend: %s", marketStruct.bullish ? "BULLISH" : "BEARISH"), 10, 40, marketStruct.bullish ? clrLime : clrRed); CreateLabel("ValidSwings", StringFormat("Valid Swings: %d", ArraySize(validSwings)), 10, 60, clrYellow); }
The helper functions provide essential support for both analysis and synchronization within the EA. In UpdateAvgCandleSize(), we calculate the average range of recent candles by summing the difference between highs and lows over a sample of bars and normalizing it in points. This value becomes a dynamic benchmark for identifying displacement, allowing us to distinguish between normal price movement and strong impulsive behavior. The FindBarIndexByTime() function complements this by mapping a specific timestamp back to its corresponding index in the price series, ensuring that all structural evaluations—such as validation and liquidity checks—are accurately aligned with the correct candle in historical data.
The UpdateVisualization() function then translates all this internal logic into a visual representation on the chart, making the system’s decisions transparent and easier to interpret. We begin by clearing previously drawn objects and then plot validated swings using colored arrows to distinguish highs and lows, while invalid candidates are shown in gray to highlight filtered noise. Liquidity zones are rendered as shaded rectangles, giving a clear view of where the market may seek liquidity next. Finally, we display key contextual information such as the current market state, trend direction, and number of validated swings using on-chart labels.
Backtest Results
The backtest was conducted on the XAUUSD pair, on the H1 timeframe over a 2-month testing window (01 December 2025 to 30 January 2026), with the default settings: 

Conclusion
In conclusion, we transformed the simplistic notion of "a swing is just a high or low" into a structured, rule-based framework that defines what truly matters in price action. By introducing validation layers such as break of structure, displacement, liquidity sweeps, and time-based respect, we move from raw detection to meaningful interpretation. Each swing must now prove its relevance through interaction, intent, and persistence, effectively filtering out noise and elevating only those points that reflect genuine market behavior. The result is a system that no longer reacts to price blindly, but instead understands why price moves, grounding every decision in observable structural logic.
Practically, after implementing this framework, you will be able to:
- Distinguish between random price fluctuations and structurally valid turning points.
- Identify where liquidity resides and anticipate how price is likely to interact with it.
- Align trade entries with confirmed market phases such as expansion or reversal.
- Anchor risk management to validated structure rather than arbitrary levels.
Warning: All rights to these materials are reserved by MetaQuotes Ltd. Copying or reprinting of these materials in whole or in part is prohibited.
This article was written by a user of the site and reflects their personal views. MetaQuotes Ltd is not responsible for the accuracy of the information presented, nor for any consequences resulting from the use of the solutions, strategies or recommendations described.
Market Simulation (Part 20): First steps with SQL (III)
MetaTrader 5 Machine Learning Blueprint (Part 10): Bet Sizing for Financial Machine Learning
Features of Experts Advisors
Market Simulation (Part 19): First Steps with SQL (II)
- Free trading apps
- Over 8,000 signals for copying
- Economic news for exploring financial markets
You agree to website policy and terms of use