preview
Decoding Market Intent: Reading Structure, Liquidity, and Price Behavior

Decoding Market Intent: Reading Structure, Liquidity, and Price Behavior

MetaTrader 5Examples |
173 0
Hlomohang John Borotho
Hlomohang John Borotho

Table of Contents

  1. Introduction
  2. System Overview
  3. Getting Started
  4. Backtest and Demo
  5. Conclusion


Introduction

Anyone who has spent a season trading structure and liquidity knows the specific frustration this article addresses. We open a chart, we load an indicator that promises Smart Money analysis, and within seconds the chart is buried. There are break-of-structure labels on every minor swing. There are fair value gaps stacked four deep. There are liquidity lines running through every old high. Every one of those objects is technically correct, and together they say nothing. We are left doing the entire job by hand anyway, because the tool told us what happened without ever telling us what it meant. Worse, the labels tend to appear in hindsight. A swing high is printed only after the market has already moved past it, so the chart we study on Sunday is not the chart we could have traded on Tuesday.

The problem is not that these concepts are wrong. Structure, liquidity, and displacement are genuinely useful ways to read a market. The problem is that most implementations stop at annotation. They detect a pattern and draw it. They never take the next step, which is to weigh what was detected against everything else that is true at the same moment, and then commit to a position on what the market is likely doing. That step is the difference between a chart decoration and a decision system. In this article we build that step. We define market intently operationally as a directional hypothesis inferred from observable changes in structure, liquidity interaction, and price behavior. We compute it as a number we can act on or ignore.

Our solution is to decode market intent. We do that by running a five-stage pipeline over four timeframes, which produces a single Market Intent Score between zero and one hundred, and reduces that score to one of five decision states. When AutoTrade is switched off, it behaves as a decision-support indicator with a compact dashboard and a few meaningful chart objects. When AutoTrade is switched on, the same analysis drives entries, stops, targets, and position sizing. Nothing changes between the two modes except whether orders are sent. That single property matters more than anything else in the design, because it means the chart we study is the chart the machine trades.


System Overview

The engine is built as a pipeline, and each stage answers exactly one question. Stage one asks where the market is in structural terms. Stage two asks where orders are likely clustered. Stage three asks what price actually did when it got there. Stage four asks what all of that implies. Stage five asks what we should do about it. Data flows one way, and no stage is allowed to reach back and rewrite an earlier one. This is deliberate. A great many discretionary systems fail because the trader decides on a direction first and then interprets structure to support it. A pipeline cannot do that.

Context is handled by running stage one on four timeframes at once. A macro timeframe sets the broad bias, a primary timeframe carries the structure we trade with, a setup timeframe hosts the liquidity work, and an entry timeframe supplies confirmation. The defaults are H4, H1, M15, and M5, and all four are inputs. This is what stops the system from taking every pattern it finds. The same M15 sweep means one thing when H4 and H1 agree with it and something quite different when they do not, and the score reflects that difference automatically rather than through a handwritten exception.

The output is deliberately compressed. Rather than fifty objects, we show the handful that carry information, and we back them with a dashboard that states the case in words. The dashboard reports each timeframe's trend and whether liquidity has been swept (and on which side). It also shows the last displacement strength, FVG state, last structural break, component gauges, the total score, the decision state, and the current trade plan with its reward-to-risk ratio.


Getting Started

Inputs

//+------------------------------------------------------------------+
//|                                                Market Intent.mq5 |
//|                                  Copyright 2025, MetaQuotes Ltd. |
//|                     https://www.mql5.com/en/users/johnhlomohang/ |
//+------------------------------------------------------------------+
#property copyright "Copyright 2025, MetaQuotes Ltd."
#property link      "https://www.mql5.com/en/users/johnhlomohang/"
#property version   "1.00"
#property description "Market Intent Engine - fuses market structure, liquidity mapping and price behavior"
#property description "into a single 0-100 intent score with WAIT / WATCH / ACTION decision states."
#property description "Set AutoTrade = false to use it purely as a visual indicator."

#include <Trade\Trade.mqh>

//+------------------------------------------------------------------+
//| Inputs                                                           |
//+------------------------------------------------------------------+
input group "=== MASTER SWITCH ==="
input bool             InpAutoTrade          = false;        // AutoTrade (false = indicator only)

input group "=== TIMEFRAME CONTEXT ==="
input ENUM_TIMEFRAMES  InpMacroTF            = PERIOD_H4;    // Macro structure timeframe
input ENUM_TIMEFRAMES  InpPrimaryTF          = PERIOD_H1;    // Primary structure timeframe
input ENUM_TIMEFRAMES  InpSetupTF            = PERIOD_M15;   // Setup timeframe (liquidity / sweeps)
input ENUM_TIMEFRAMES  InpEntryTF            = PERIOD_M5;    // Entry timeframe (confirmation)
input int              InpBarsMacro          = 400;          // Bars to load - macro
input int              InpBarsPrimary        = 600;          // Bars to load - primary
input int              InpBarsSetup          = 900;          // Bars to load - setup
input int              InpBarsEntry          = 900;          // Bars to load - entry

The very first input in the file is the one that defines what the product actually is, and its placement at the top is not an accident. Everything downstream of it is written so that this single boolean is the only difference between a chart study tool and a trading robot. When it is false, the engine analyzes, draws, and reports. When it is true, precisely the same analysis reaches an execution layer. We never want to be in the position of wondering whether the chart we studied all week is the chart the machine is trading, and the only reliable way to guarantee that is to make the analysis a single shared code path with a switch at the end of it.

The four timeframes each carry a distinct job rather than being four looks at the same thing. The macro timeframe supplies a bias we are reluctant to trade against, the primary timeframe carries the structure we actually trade with, the setup timeframe is where liquidity is mapped and swept, and the entry timeframe supplies the final confirmation that price has committed. Because all four are inputs rather than constants, a swing trader can shift the whole stack up to D1, H4, H1, and M15 without touching a line of code, and the relationships between the stages will hold.

input group "=== 4. INTENT SCORING (weights sum to 100) ==="
input double           InpWHTF               = 20.0;         // Weight: HTF bias alignment
input double           InpWLiq               = 20.0;         // Weight: liquidity sweep
input double           InpWDisp              = 18.0;         // Weight: displacement
input double           InpWBOS               = 14.0;         // Weight: BOS / CHOCH
input double           InpWFVG               =  8.0;         // Weight: FVG present
input double           InpWRetest            =  8.0;         // Weight: FVG retest
input double           InpWReject            =  5.0;         // Weight: rejection
input double           InpWLoc               =  7.0;         // Weight: premium / discount location
input double           InpConflictWeight     = 0.30;         // Opposite-side penalty factor
input double           InpWatchScore         = 45.0;         // Score >= this -> WATCH
input double           InpActionScore        = 65.0;         // Score >= this -> ACTION

The scoring weights are exposed as inputs too, and they are chosen so that they sum to one hundred. This matters more than it might appear. A score is only interpretable when its maximum is known, and if you suspect that displacement deserves more influence than higher-timeframe bias, you can settle the question by editing two numbers rather than by unpicking the scoring function. The weights are the system's opinions made explicit.

input group "=== 5. DECISION GATES ==="
input bool             InpRequireSweep       = true;         // ACTION requires a fresh liquidity sweep
input bool             InpRequireDisplacement= true;         // ACTION requires aligned displacement
input bool             InpRequireBOS         = true;         // ACTION requires aligned BOS / CHOCH
input bool             InpRequireFVG         = false;        // ACTION requires an unfilled FVG
input double           InpMinRR              = 1.50;         // Minimum reward-to-risk
input double           InpDefaultRR          = 2.00;         // RR used when no pool target exists
input double           InpSLBufferATR        = 0.35;         // Stop buffer beyond invalidation (x ATR)
input double           InpMinTPDistATR       = 1.00;         // Ignore targets closer than this

Alongside the score, we expose a set of hard gates, and the separation between the two is a deliberate design decision. A weighted score is good at expressing degree, and poor at expressing necessity. If we fold a requirement into the score as a large weight, a setup can still reach the threshold without it by accumulating points elsewhere. This can lead to trades with no liquidity event behind them, simply because the trend and location looked acceptable. The gates fix that by sitting outside the arithmetic entirely. A high score alone cannot produce an ACTION state; the gates must also clear. That gives us one continuous control for quality and one binary control for admissibility, and the two cannot mask each other.

The data structures

//+------------------------------------------------------------------+
//| Data structures                                                  |
//+------------------------------------------------------------------+
struct SwingPoint
  {
   datetime          time;        // bar time of the swing
   double            price;       // swing price
   int               shift;       // series index in its source array
   int               confirmAt;   // series index where the swing becomes known
   bool              isHigh;      // true = swing high, false = swing low
   int               label;       // LBL_HH / LBL_HL / LBL_LH / LBL_LL
   bool              broken;      // a close has traded through it
   bool              swept;       // wick took it but close came back
  };

Before writing any analysis, we define what the system is permitted to know, because the shape of the state determines the questions later stages can ask. Six structures carry the entire engine, and each field in them exists because something downstream needs it. The swing point is the foundation, and two of its fields are worth dwelling on.

The confirmAt field is the one that keeps the whole system honest. The pairing of "broken" and "swept" encodes a distinction that a great many implementations collapse into a single flag, to their cost. A level that a candle closed through is finished; it has been absorbed into the structure and it no longer acts as a barrier. A level that a wick pierced before price closed back inside is not finished at all. It is arguably more interesting than it was before the pierce, because we now know that orders sat there and that somebody was willing to defend the level after taking them. Storing these as two separate booleans lets the structure engine and the liquidity engine draw opposite conclusions from the same candle, which is precisely what we want.

struct LiquidityPool
  {
   double            price;
   datetime          time;
   int               side;        // +1 buy-side (highs), -1 sell-side (lows)
   int               type;        // POOL_*
   int               touches;
   bool              swept;
   datetime          sweptTime;
   double            strength;    // 0..1
   string            tag;
  };

Liquidity is stored as pools rather than as lines, so that repeated interaction with the same area can accumulate instead of cluttering the map with near-duplicates. The touches and strength fields together let the map express the difference between a single forgotten swing high and a shelf of four equal highs that price has probed repeatedly. Both are buy-side liquidity, but only one of them is the kind of level a large participant would bother to run. A sweep is stored the same way, as a graded event rather than a boolean, because the useful question is never whether price touched a level.

struct SweepEvent
  {
   bool              valid;
   int               side;          // +1 buy-side taken, -1 sell-side taken
   double            level;
   double            extreme;       // wick extreme of the sweep bar
   double            excursionPts;
   bool              closedBack;
   bool              displacement;
   bool              structBreak;
   datetime          time;
   int               barShift;      // setup-TF shift of the sweep bar
   int               reactionDir;
   string            tag;
  };

Every one of those fields answers a question the scoring stage will ask. How far beyond the level did price travel? Did it close back inside, did anything happen afterwards? How long ago was it, and what was the level in the first place? By computing them once at detection time and storing them on the event, we avoid the common trap of recomputing the same conditions in three different places and letting them drift apart.

Stage one: the market structure engine

//+------------------------------------------------------------------+
//| STAGE 1 : structure engine                                       |
//+------------------------------------------------------------------+
void DetectSwings(const MqlRates &rates[], int n, int depth, SwingPoint &out[])
  {
   ArrayResize(out, 0);
   if(n < depth * 2 + 5)
      return;

   for(int i = n - depth - 1; i >= depth; i--)     // oldest -> newest
     {
      bool isHigh = true;
      bool isLow  = true;

      for(int k = 1; k <= depth; k++)
        {
         if(rates[i].high <= rates[i + k].high || rates[i].high <= rates[i - k].high)
            isHigh = false;
         if(rates[i].low  >= rates[i + k].low  || rates[i].low  >= rates[i - k].low)
            isLow = false;
         if(!isHigh && !isLow)
            break;
        }

      if(isHigh)
        {
         int sz = ArraySize(out);
         ArrayResize(out, sz + 1);
         out[sz].time      = rates[i].time;
         out[sz].price     = rates[i].high;
         out[sz].shift     = i;
         out[sz].confirmAt = i - depth;
         out[sz].isHigh    = true;
         out[sz].label     = LBL_NONE;
         out[sz].broken    = false;
         out[sz].swept     = false;
        }

      if(isLow)
        {
         int sz = ArraySize(out);
         ArrayResize(out, sz + 1);
         out[sz].time      = rates[i].time;
         out[sz].price     = rates[i].low;
         out[sz].shift     = i;
         out[sz].confirmAt = i - depth;
         out[sz].isHigh    = false;
         out[sz].label     = LBL_NONE;
         out[sz].broken    = false;
         out[sz].swept     = false;
        }
     }
  }

The swing detection itself is the ordinary fractal definition. For a swing depth of N, a bar is a swing high when its high exceeds the highs of the N bars on either side of it, and a swing low when the mirror condition holds. We test both in the same pass and break out early once neither can be true, which keeps the loop cheap on the longer histories the macro timeframe needs.

What is not ordinary is what we do with the result, and this is the point at which the engine departs from most published implementations. A swing at index i cannot be recognized until N further bars have printed to its right. That is not a quirk of the code; it is a property of the definition. The swing did not exist as information at the moment it formed, and any system that treats it as though it did is quietly reading the future. So we record the lag explicitly on the point itself rather than leaving it implicit.

//+------------------------------------------------------------------+
//| Build Structure                                                  |
//+------------------------------------------------------------------+
void BuildStructure(const MqlRates &rates[], int n, SwingPoint &sw[],
                    TFStruct &st, BreakEvent &ev[])
  {
    /*
    Walk the bars chronologically, activate swings when they become  
    known, label them HH/HL/LH/LL, and register BOS / CHOCH events.*/

   ArrayResize(ev, 0);

   st.trend          = 0;
   st.lastHH = st.lastHL = st.lastLH = st.lastLL = 0.0;
   st.protHigh = st.protLow = 0.0;
   st.protHighTime = st.protLowTime = 0;
   st.lastBreakDir   = 0;
   st.lastBreakChoch = false;
   st.lastBreakTime  = 0;
   st.lastBreakLevel = 0.0;
   st.lastBreakShift = -1;

   int nsw = ArraySize(sw);
   if(nsw < 2 || n < 10)
      return;

   int    k        = 0;      // next swing waiting for activation
   double actHigh  = 0.0, actLow = 0.0;
   datetime actHighTime = 0, actLowTime = 0;
   int    actHighIdx = -1, actLowIdx = -1;
   double lastSwHigh = 0.0, lastSwLow = 0.0;

   for(int j = n - 1; j >= 0; j--)      // oldest -> newest
     {
      //--- activate every swing that becomes visible on this bar
      while(k < nsw && sw[k].confirmAt >= j)
        {
         if(sw[k].isHigh)
           {
            sw[k].label = (lastSwHigh > 0.0 && sw[k].price > lastSwHigh) ? LBL_HH : LBL_LH;
            lastSwHigh  = sw[k].price;
            actHigh     = sw[k].price;
            actHighTime = sw[k].time;
            actHighIdx  = k;

With the lag stored, the structure walk can respect it. We iterate the bars from oldest to newest, and a swing is only promoted to an active level once the walk actually reaches its confirmation bar. Everything the engine concludes at bar j is therefore derived from information that existed at bar j, and the consequences of that are larger than they sound. The chart the engine draws over history is the chart it would have drawn in real time, which means the labels we study on a Sunday are the labels we could have acted on. It also means that when the engine is later asked to place a stop below a protected low, that low is a level the market had already established rather than one that only became visible in hindsight.

            //--- retro check: did price already close through it while unconfirmed?
            double maxClose = -DBL_MAX;
            for(int b = sw[k].shift - 1; b >= j; b--)
               if(rates[b].close > maxClose)
                  maxClose = rates[b].close;

            if(maxClose > actHigh)
              {
               RegisterBreak(ev, st, actHighTime, actHigh, rates[j].time, j, 1);
               sw[k].broken = true;
               if(actLow > 0.0)
                 { st.protLow = actLow; st.protLowTime = actLowTime; }
               actHigh = 0.0;
               actHighIdx = -1;
              }

Honesty about the confirmation lag introduces a complication that has to be handled rather than ignored. During the N bars in which a swing high is still unconfirmed, price is perfectly free to close above it. By the time the swing becomes visible to the walk, it has already been broken, and if we simply activate it as fresh resistance, we leave a phantom level sitting on the chart that the market dealt with some time ago. We therefore look back over the unconfirmed window at the moment of activation, and if price has already closed through the level, we register the break immediately, we timestamp it at the confirmation bar rather than at the original close. Dating it to the confirmation bar is the conservative choice, because that is the earliest moment at which the engine could legitimately have known that a break had occurred.

//+------------------------------------------------------------------+
//| Register a structural break and flip the trend when needed       |
//+------------------------------------------------------------------+
void RegisterBreak(BreakEvent &ev[], TFStruct &st, datetime fromTime, double level,
                   datetime breakTime, int breakShift, int dir)
  {
   bool choch = (st.trend != 0 && st.trend != dir);

   int sz = ArraySize(ev);
   ArrayResize(ev, sz + 1);
   ev[sz].fromTime  = fromTime;
   ev[sz].level     = level;
   ev[sz].breakTime = breakTime;
   ev[sz].dir       = dir;
   ev[sz].choch     = choch;

   st.trend          = dir;
   st.lastBreakDir   = dir;
   st.lastBreakChoch = choch;
   st.lastBreakTime  = breakTime;
   st.lastBreakLevel = level;
   st.lastBreakShift = breakShift;
  }

Once the walk is running, the distinction between a break of structure and a change of character requires no separate detector at all. Both are the same event; the difference lies entirely in what the trend was beforehand. A break that continues the prevailing direction is a BOS, and a break that reverses it is a CHOCH. Writing it this way rather than as two independent pattern matchers removes an entire class of bug, because it becomes structurally impossible for the engine to report a bullish BOS and a bullish CHOCH from the same candle.

//+------------------------------------------------------------------+
//| Analyze TimeFrames                                               |
//+------------------------------------------------------------------+
int AnalyzeTF(ENUM_TIMEFRAMES tf, int bars, int depth, MqlRates &rates[],
              SwingPoint &sw[], TFStruct &st, BreakEvent &ev[])
  {
   ArraySetAsSeries(rates, true);
   int copied = CopyRates(_Symbol, tf, 0, bars, rates);
   if(copied < depth * 2 + 20)
      return 0;

   DetectSwings(rates, copied, depth, sw);
   BuildStructure(rates, copied, sw, st, ev);
   return copied;
  }

All of this runs on four timeframes through a single helper, which is what makes the multi-timeframe context affordable to maintain. There is no separate macro analyzer and no special-cased entry logic. There is one structure engine, called four times with different parameters, writing into four sets of arrays. When we later improve the swing classification, all four timeframes improve together.

Protected highs and lows

      //--- upside break of the active swing high
      if(actHigh > 0.0 && rates[j].close > actHigh)
        {
         RegisterBreak(ev, st, actHighTime, actHigh, rates[j].time, j, 1);
         if(actHighIdx >= 0)
            sw[actHighIdx].broken = true;
         if(actLow > 0.0)
           { st.protLow = actLow; st.protLowTime = actLowTime; }
         actHigh = 0.0;
         actHighIdx = -1;
        }
      //--- wick-only violation of the active swing high = a sweep
      else
         if(actHigh > 0.0 && rates[j].high > actHigh && rates[j].close < actHigh)
           {
            if(actHighIdx >= 0)
               sw[actHighIdx].swept = true;
           }

Not every swing carries the same weight, and treating them as equals is one of the quieter reasons that automated structure systems misbehave. In a bullish market, the swing low that existed at the instant the market broke a high is the level that holds the whole trend together. While it survives, the bullish reading remains valid no matter how untidy the intervening price action becomes. If the price decisively breaks it, the bullish reading is invalidated regardless of how many higher highs preceded it. The difficulty is that this level is only unambiguous at one moment, namely the moment of the break, so we capture it there rather than trying to reconstruct it afterward.

The second branch is where the structure engine quietly hands work to the liquidity engine, and the else is doing real work. A close through a level is a structural event and the level is consumed. A wick through the same level with a close back inside is not structural in the slightest, and treating it as a break would have the engine flipping its trend reading on every stop run. Instead, it is tagged as swept and left in place. The practical payoff of protected levels arrives much later, at the point where the system needs a stop loss. We are not placing stops a round number of points from entry, and we are not placing them at an ATR multiple chosen because it backtested well. We are placing them at the price which, if traded, means our reading of the market was simply wrong.

Stage two: the liquidity engine

//+------------------------------------------------------------------+
//| STAGE 2 : liquidity engine                                       |
//+------------------------------------------------------------------+
void AddPool(double price, datetime time, int side, int type, string tag, double strength)
  {
   if(price <= 0.0)
      return;

   double tol = g_atrSetup * InpEqualTolATR;
   if(tol <= 0.0)
      tol = 10 * Pt();

//--- merge with an existing pool at the same level
   for(int i = 0; i < ArraySize(g_pools); i++)
     {
      if(g_pools[i].side != side)
         continue;
      if(MathAbs(g_pools[i].price - price) <= tol)
        {
         g_pools[i].touches++;
         if(time > g_pools[i].time)
            g_pools[i].time = time;
         //--- an equal-level cluster is stronger than a single swing
         g_pools[i].strength = MathMin(1.0, g_pools[i].strength + 0.22);
         if(InpUseEqualPools && g_pools[i].type == POOL_SWING)
           {
            g_pools[i].type = POOL_EQUAL;
            g_pools[i].tag  = (side > 0 ? "EQH" : "EQL");
           }
         return;
        }
     }

   int sz = ArraySize(g_pools);
   ArrayResize(g_pools, sz + 1);
   g_pools[sz].price     = price;
   g_pools[sz].time      = time;
   g_pools[sz].side      = side;
   g_pools[sz].type      = type;
   g_pools[sz].touches   = 1;
   g_pools[sz].swept     = false;
   g_pools[sz].sweptTime = 0;
   g_pools[sz].strength  = strength;
   g_pools[sz].tag       = tag;
  }

The engine makes no claim to know where orders sit, because it cannot. What it can do is identify the price areas where liquidity is reasonably inferred from observable market structure, which is a far more defensible position and, in practice, sufficient. Four sources feed the map: unbroken swing highs and lows drawn from both the primary and setup timeframes, clusters of equal levels, previous day extremes, and previous week extremes. Each source is independently switchable, so a trader who does not believe in weekly levels can remove them without disturbing the rest.

Merging is what turns a scatter of lines into a usable map. When a candidate level lands within tolerance of an existing pool on the same side, we do not create a second entry. We raise the touch count, increase the strength, and promote the pool from an ordinary swing to an equal-highs or equal-lows cluster. The promotion is the important part, because a shelf of equal highs is a qualitatively different object from a single swing high, and by the time it has been touched three times the map should be saying so.

The tolerance is expressed as a fraction of ATR rather than as a number of points, and this is not a stylistic preference. Two highs that a gold trader would call equal are separated by a distance that would represent an enormous gap on a major currency pair. A fixed-point tolerance would merge half the chart on one symbol and nothing at all on the other, and the engine would need per-symbol tuning simply to remain coherent. Scaling by ATR means the same default behaves sensibly across instruments and, just as importantly, adapts as volatility changes on a single instrument.

Strength is seeded by source, with a previous week extreme starting at 0.85, a previous day extreme at 0.70, a primary-timeframe swing at 0.45, and a setup-timeframe swing at 0.30. These figures are not precise measurements of anything and should not be read as such. What matters is the ordering they express, which is that levels visible to more participants and standing for longer are the ones more worth running.

Liquidity sweeps properly defined

//+------------------------------------------------------------------+
//| Scan Sweeps                                                      |
//+------------------------------------------------------------------+
void ScanSweeps()
  {
   g_sweep.valid        = false;
   g_sweep.side         = 0;
   g_sweep.level        = 0.0;
   g_sweep.extreme      = 0.0;
   g_sweep.excursionPts = 0.0;
   g_sweep.closedBack   = false;
   g_sweep.displacement = false;
   g_sweep.structBreak  = false;
   g_sweep.time         = 0;
   g_sweep.barShift     = -1;
   g_sweep.reactionDir  = 0;
   g_sweep.tag          = "";

   if(g_nSetup < 20 || g_atrSetup <= 0.0)
      return;

   double minPen  = g_atrSetup * InpMinPenetrationATR;
   int    maxScan = MathMin(InpSweepScanBars, g_nSetup - 5);
   int    best    = -1;
   int    bestPool = -1;

   for(int j = 1; j <= maxScan; j++)
     {
      for(int p = 0; p < ArraySize(g_pools); p++)
        {
         if(g_pools[p].time >= g_rSetup[j].time)
            continue;

         if(g_pools[p].side > 0)
           {
            //--- buy-side liquidity above the market
            if(g_rSetup[j].high > g_pools[p].price + minPen &&
               g_rSetup[j].close < g_pools[p].price)
              {
               g_pools[p].swept     = true;
               g_pools[p].sweptTime = g_rSetup[j].time;
               if(best < 0 || j < best)
                 { best = j; bestPool = p; }
              }
           }
         else
           {
            //--- sell-side liquidity below the market
            if(g_rSetup[j].low < g_pools[p].price - minPen &&
               g_rSetup[j].close > g_pools[p].price)
              {
               g_pools[p].swept     = true;
               g_pools[p].sweptTime = g_rSetup[j].time;
               if(best < 0 || j < best)
                 { best = j; bestPool = p; }
              }
           }
        }
     }

This is the section where the system stops describing the chart and starts reading it, and it rests on a single insistence. A sweep is not price touching a level. A sweep is a sequence, and the sequence has to occur in order: price approaches the level, trades beyond it, fails to continue, closes back inside, and then reacts away from it. Any implementation that stops at the first two steps will mark a sweep on every level that price is in the process of breaking, which is the opposite of the information we want. We therefore test the trade-beyond and the close-back-inside as a joint condition on the same candle.

//--- reaction: opposite displacement inside the allowed window
   int wanted = (g_sweep.side > 0 ? -1 : 1);
   for(int b = j; b >= MathMax(1, j - InpSweepReactBars); b--)
     {
      int    dir   = 0;
      double score = 0.0;
      if(IsDisplacementBar(g_rSetup, b, g_atrSetup, dir, score) && dir == wanted)
        {
         g_sweep.displacement = true;
         g_sweep.reactionDir  = dir;
         break;
        }
     }

//--- reaction: a structural break in the same direction after the sweep
   if(g_stSetup.lastBreakDir == wanted && g_stSetup.lastBreakTime >= g_sweep.time)
      g_sweep.structBreak = true;
   else
      if(g_stEntry.lastBreakDir == wanted && g_stEntry.lastBreakTime >= g_sweep.time)
         g_sweep.structBreak = true;
  }

Note the direction inversion held in wanted. When buy-side liquidity above the market is taken, the reaction we are looking for is bearish, and when sell-side liquidity below the market is taken, the reaction is bullish. Getting this backwards is one of the easiest mistakes to make in this kind of code and one of the hardest to spot afterward, because the system will still trade and will still sometimes win. Isolating the inversion in a single named variable, used consistently in both reaction checks, makes it a one-line thing to verify.

Stage three: the price behavior engine

//+------------------------------------------------------------------+
//| STAGE 3 : price behavior engine                                  |
//+------------------------------------------------------------------+
bool IsDisplacementBar(const MqlRates &r[], int i, double atr, int &dir, double &score)
  {
   dir   = 0;
   score = 0.0;
   if(atr <= 0.0 || i < 0)
      return false;

   double range = r[i].high - r[i].low;
   if(range <= 0.0)
      return false;

   double body      = MathAbs(r[i].close - r[i].open);
   double bodyRatio = body / range;
   double rangeMult = range / atr;

   if(rangeMult < InpDispATRMult || bodyRatio < InpDispBodyRatio)
      return false;

   dir   = (r[i].close > r[i].open) ? 1 : -1;
   score = MathMin(1.0, 0.60 * MathMin(1.0, rangeMult / (InpDispATRMult * 2.0)) + 0.40 * bodyRatio);
   return true;
  }

Structure tells us where price is, and liquidity tells us what price interacted with. Behavior tells us what price actually did, and displacement is the primary measurement. We define it as a joint condition on range and body rather than on range alone, and the reason is straightforward. A candle can be three times the average range and still represent nothing but indecision if it spent the session swinging around and closed near its open. Intent shows up in the body. Requiring both a wide range relative to recent volatility and a body that occupies most of that range filters out the wide indecisive candles that would otherwise dominate any pure range test.

//+------------------------------------------------------------------+
//| Analyze Behavior                                                 |
//+------------------------------------------------------------------+
void AnalyzeBehavior()
  {
   g_beh.dispValid   = false;
   g_beh.dispDir     = 0;
   g_beh.dispScore   = 0.0;
   g_beh.dispHigh    = 0.0;
   g_beh.dispLow     = 0.0;
   g_beh.dispTime    = 0;
   g_beh.dispShift   = -1;
   g_beh.consecutive = 0;
   g_beh.bodyRatio   = 0.0;
   g_beh.wickUpRatio = 0.0;
   g_beh.wickDnRatio = 0.0;
   g_beh.rejectUp    = false;
   g_beh.rejectDown  = false;
   g_beh.momentum    = 0.0;

   if(g_nSetup < 10 || g_atrSetup <= 0.0)
      return;

//--- most recent displacement leg on the setup timeframe
   int scan = MathMin(InpDispScanBars, g_nSetup - 3);
   for(int i = 1; i <= scan; i++)
     {
      int    dir   = 0;
      double score = 0.0;
      if(IsDisplacementBar(g_rSetup, i, g_atrSetup, dir, score))
        {
         g_beh.dispValid = true;
         g_beh.dispDir   = dir;
         g_beh.dispScore = score;
         g_beh.dispTime  = g_rSetup[i].time;
         g_beh.dispShift = i;

         //--- widen the leg over adjacent same-direction candles
         double hi = g_rSetup[i].high;
         double lo = g_rSetup[i].low;
         for(int k = i + 1; k <= MathMin(i + 3, g_nSetup - 1); k++)
           {
            bool same = (dir > 0) ? (g_rSetup[k].close > g_rSetup[k].open)
                        : (g_rSetup[k].close < g_rSetup[k].open);
            if(!same)
               break;
            hi = MathMax(hi, g_rSetup[k].high);
            lo = MathMin(lo, g_rSetup[k].low);
           }

Having found a displacement candle, we widen it into a leg, walking outwards over adjacent candles that share its direction. This matters because of what we do with the result later. A three-candle thrust is one move, and if we measure the retracement of only its final candle we will place an entry zone somewhere in the middle of the move rather than at the level price is likely to return to. Taking the extremes of the whole leg gives us a retracement measurement that corresponds to what a trader reading the chart would draw by hand.

//--- rejection profile of the last closed setup candle
   double range = g_rSetup[1].high - g_rSetup[1].low;
   if(range > 0.0)
     {
      double body   = MathAbs(g_rSetup[1].close - g_rSetup[1].open);
      double upWick = g_rSetup[1].high - MathMax(g_rSetup[1].close, g_rSetup[1].open);
      double dnWick = MathMin(g_rSetup[1].close, g_rSetup[1].open) - g_rSetup[1].low;

      g_beh.bodyRatio   = body / range;
      g_beh.wickUpRatio = upWick / range;
      g_beh.wickDnRatio = dnWick / range;

      g_beh.rejectUp   = (g_beh.wickDnRatio >= InpRejWickRatio &&
                          g_rSetup[1].close > g_rSetup[1].open);
      g_beh.rejectDown = (g_beh.wickUpRatio >= InpRejWickRatio &&
                          g_rSetup[1].close < g_rSetup[1].open);
     }

   double mom = 0.0;
   mom += 0.5 * MathMin(1.0, MathAbs((double)g_beh.consecutive) / 4.0);
   mom += 0.5 * g_beh.bodyRatio;
   g_beh.momentum = MathMin(1.0, mom);
  }

Rejection is measured on the last closed setup candle as a wick-to-range ratio combined with the direction of the close, and the combination is what makes it meaningful. A long lower wick is often described as bullish on its own, but a long lower wick on a candle that closed down is a failed recovery rather than a rejection, and the engine refuses to read it as bullish. This small piece of code encodes an important idea: which is that no single feature of a candle means anything without the rest of the surrounding candle.

Fair value gaps as graded objects

//+------------------------------------------------------------------+
//| Fair value gaps / imbalances on the setup timeframe              |
//+------------------------------------------------------------------+
void DetectFVGs()
  {
   ArrayResize(g_fvgs, 0);
   if(g_nSetup < 10 || g_atrSetup <= 0.0)
      return;

   double minSize = g_atrSetup * InpMinFVGATR;
   int    scan    = MathMin(InpFVGScanBars, g_nSetup - 4);

   for(int i = 1; i <= scan; i++)
     {
      double up = 0.0, lo = 0.0;
      int    dir = 0;

      if(g_rSetup[i].low > g_rSetup[i + 2].high)
        {
         dir = 1;
         lo  = g_rSetup[i + 2].high;
         up  = g_rSetup[i].low;
        }
      else
         if(g_rSetup[i].high < g_rSetup[i + 2].low)
           {
            dir = -1;
            lo  = g_rSetup[i].high;
            up  = g_rSetup[i + 2].low;
           }

      if(dir == 0)
         continue;
      if((up - lo) < minSize)
         continue;

      //--- how deeply has price traded back into the gap since it formed?
      double deepest = (dir > 0 ? up : lo);
      bool   tapped  = false;
      for(int b = i - 1; b >= 0; b--)
        {
         if(dir > 0)
           {
            if(g_rSetup[b].low <= up)
              { tapped = true; deepest = MathMin(deepest, g_rSetup[b].low); }
           }
         else
           {
            if(g_rSetup[b].high >= lo)
              { tapped = true; deepest = MathMax(deepest, g_rSetup[b].high); }
           }
        }

      double ratio = 0.0;
      double width = up - lo;
      if(width > 0.0)
         ratio = (dir > 0) ? (up - deepest) / width : (deepest - lo) / width;
      ratio = MathMax(0.0, MathMin(1.0, ratio));

      if(ratio > InpMaxFVGFill)
         continue;                                  // effectively consumed

      int sz = ArraySize(g_fvgs);
      ArrayResize(g_fvgs, sz + 1);
      g_fvgs[sz].upper     = up;
      g_fvgs[sz].lower     = lo;
      g_fvgs[sz].dir       = dir;
      g_fvgs[sz].time      = g_rSetup[i].time;
      g_fvgs[sz].fillRatio = ratio;
      g_fvgs[sz].tapped    = tapped;
      g_fvgs[sz].shift     = i;
     }
  }

A fair value gap is trivially easy to detect and remarkably easy to misuse. The detection is the standard three-candle imbalance, where the wick of the first candle and the wick of the third fail to overlap, leaving a band of price through which the market moved without trading in both directions.

The useful work happens afterward, in how we track what becomes of the gap. Most implementations store a filled flag and remove the gap once the price touches it, which discards the entire middle of the story. The interesting region is precisely the space between untouched and consumed. A gap that has been tapped by ten percent is a live magnet with plenty of unfilled area left to attract price. A gap that has been filled by ninety percent has done its job, and continuing to score it as a reason to trade is how a system ends up buying into an area that has already been rebalanced. We therefore compute and store a fill ratio, and discard the gap only when the ratio exceeds a configurable maximum.

Stage four: intent scoring

//+------------------------------------------------------------------+
//| STAGE 4 : intent scoring                                         |
//+------------------------------------------------------------------+
void ScoreIntent()
  {
   double bull = 0.0, bear = 0.0;
   double sStruct = 0.0, sLiq = 0.0, sDisp = 0.0, sLoc = 0.0;

//--- (a) higher timeframe bias 
   if(g_stMacro.trend > 0)
      bull += InpWHTF * 0.60;
   else
      if(g_stMacro.trend < 0)
         bear += InpWHTF * 0.60;

   if(g_stPrim.trend > 0)
      bull += InpWHTF * 0.40;
   else
      if(g_stPrim.trend < 0)
         bear += InpWHTF * 0.40;

Here the pipeline converges. We compute a bullish total and a bearish total independently, then resolve them against each other. Computing both rather than a single signed number is what lets the engine recognize a market that is arguing with itself, and that recognition turns out to be one of the more valuable things it does. A single signed score would report a market with strong bullish structure and strong bearish location as mildly bullish, which is not what a human reading the same chart would conclude. Two totals plus a resolution step reports it as conflicted, which is correct.

Higher-timeframe bias is split so that the macro timeframe carries more influence than the primary one, and so that partial alignment earns partial credit. A setup where H4 and H1 both point the same way should not score the same as one where they disagree, and neither should be a simple pass or fail.

//--- (b) liquidity 
   if(g_sweep.valid)
     {
      double rec = RecencyFactor(g_sweep.barShift, InpSweepValidBars);
      double q   = rec;
      if(g_sweep.displacement)
         q *= 1.00;
      else
         q *= 0.65;
      if(g_sweep.structBreak)
         q *= 1.00;
      else
         q *= 0.80;

      if(g_sweep.side < 0)
         bull += InpWLiq * q;    // sell-side taken -> bullish
      else
         bear += InpWLiq * q;    // buy-side taken  -> bearish

      sLiq = q;
     }

The liquidity contribution is where the grading work from stage two is finally spent. Recency, displacement, and structure break each modulate the available weight multiplicatively, so a fresh sweep that produced both a displacement and a break keeps essentially all of its twenty points, while a stale sweep that produced neither retains roughly half. The direction is inverted, for the reason discussed earlier, and writing the inversion with an explanatory comment on each branch is cheap insurance against a future edit getting it backwards.

//--- (g) premium / discount location 
   double rHigh = g_stPrim.rangeHigh;
   double rLow  = g_stPrim.rangeLow;
   if(rHigh > rLow)
     {
      double eq   = (rHigh + rLow) * 0.5;
      double half = (rHigh - rLow) * 0.5;
      double dev  = (px - eq) / half;                 // -1 = deep discount, +1 = deep premium
      dev = MathMax(-1.0, MathMin(1.0, dev));
      if(dev < 0.0)
         bull += InpWLoc * MathAbs(dev);
      else
         bear += InpWLoc * dev;
      sLoc = MathAbs(dev);
     }

Location contributes through a premium and discount reading taken from the primary timeframe range, and it is the component that most often keeps the engine out of a bad trade. Buying at a discount and selling at a premium is rewarded, and the reward scales continuously with distance from equilibrium rather than switching at the midpoint. The effect in practice is that a bullish setup appearing near the top of the range loses several points relative to the same setup appearing near the bottom, which is usually enough to move it out of ACTION and into WATCH.

Stage five: the decision engine and trade plan

   if(g_intent.score >= InpActionScore && gates)
      g_intent.state = (d > 0) ? STATE_ACTION_LONG : STATE_ACTION_SHORT;
   else
      if(g_intent.score >= InpWatchScore && d != 0)
         g_intent.state = (d > 0) ? STATE_WATCH_LONG : STATE_WATCH_SHORT;
      else
         g_intent.state = STATE_WAIT;

The engine never emits a bare buy or sell, and the middle tier of its output is what makes it usable by a human rather than only by a broker. "WAIT" means there is no sufficiently strong alignment and the chart can be left alone. "WATCH" means conditions are developing, and this is the moment to be at the screen. "ACTION" means the required conditions are satisfied. A trader running the system as an indicator spends most of their day ignoring it and reacts to the WATCH transition, which is a far more realistic workflow than staring at a chart hoping to catch an entry.

//+------------------------------------------------------------------+
//| STAGE 5 : trade plan                                             |
//+------------------------------------------------------------------+
void BuildPlan()
  {
   g_plan.valid = false;
   g_plan.dir   = 0;
   g_plan.note  = "";

   int d = g_intent.direction;
   if(d == 0 || g_intent.state == STATE_WAIT)
      return;

   double atr = (g_atrSetup > 0.0 ? g_atrSetup : g_atrEntry);
   if(atr <= 0.0)
      return;

   double zLow = 0.0, zHigh = 0.0;
   int f = FindFVG(d);

   if(f >= 0)
     {
      zLow  = g_fvgs[f].lower;
      zHigh = g_fvgs[f].upper;
      g_plan.note = "FVG zone";
     }
   else
      if(g_beh.dispValid && g_beh.dispDir == d && g_beh.dispHigh > g_beh.dispLow)
        {
         //--- optimal trade entry band of the displacement leg
         double legLow  = g_beh.dispLow;
         double legHigh = g_beh.dispHigh;
         double range   = legHigh - legLow;
         if(d > 0)
           {
            zHigh = legHigh - 0.62 * range;
            zLow  = legHigh - 0.79 * range;
           }
         else
           {
            zLow  = legLow + 0.62 * range;
            zHigh = legLow + 0.79 * range;
           }
         g_plan.note = "OTE band";
        }
      else
        {
         double px = (d > 0 ? Ask() : Bid());
         zLow  = px - atr * 0.25;
         zHigh = px + atr * 0.25;
         g_plan.note = "market band";
        }

   if(zHigh < zLow)
     {
      double t = zHigh;
      zHigh = zLow;
      zLow = t;
     }

//--- invalidation: beyond the sweep extreme / protected level 
   double inval = 0.0;
   if(d > 0)
     {
      inval = zLow;
      if(g_sweep.valid && g_sweep.side < 0 && g_sweep.extreme > 0.0)
         inval = MathMin(inval, g_sweep.extreme);
      if(g_stSetup.protLow > 0.0)
         inval = MathMin(inval, g_stSetup.protLow);
      if(g_beh.dispValid && g_beh.dispDir > 0)
         inval = MathMin(inval, g_beh.dispLow);
      g_plan.sl = inval - atr * InpSLBufferATR;
     }

The invalidation level is chosen as the most conservative of three candidates rather than by a single rule, and the three candidates are exactly the three places where a long trade would be proven wrong. The far edge of the entry zone is one, because if price passes cleanly through the zone the imbalance thesis has failed. The extreme of the sweep that started the move is another, because trading below it means the low that supposedly held did not hold. The protected low from the structure engine is the third, because breaking it invalidates the bullish structure outright. Taking the minimum of all three and then pushing beyond it by an ATR-scaled buffer keeps the stop off the exact price that everybody else is watching.

//--- targets from the liquidity map 
   double minDist = atr * InpMinTPDistATR;
   double tp1 = FindTarget(d, entry, minDist, 0.0);
   if(tp1 == 0.0)
      tp1 = (d > 0) ? entry + risk * InpDefaultRR : entry - risk * InpDefaultRR;

   double tp2 = FindTarget(d, entry, minDist, tp1);
   if(tp2 == 0.0)
      tp2 = (d > 0) ? entry + risk * (InpDefaultRR + 1.0)
            : entry - risk * (InpDefaultRR + 1.0);

   g_plan.valid    = true;
   g_plan.dir      = d;
   g_plan.zoneLow  = zLow;
   g_plan.zoneHigh = zHigh;
   g_plan.entry    = entry;
   g_plan.tp1      = tp1;
   g_plan.tp2      = tp2;
   g_plan.rr       = MathAbs(tp1 - entry) / risk;
  }

Targets come from the liquidity map rather than from a fixed multiple, and this is the natural conclusion of everything built up to this point. If we are long because sell-side liquidity was taken and price displaced upward, then the logical destination is the next unswept buy-side pool, since that is where the next concentration of orders sits waiting. Only when no such pool exists within a sensible distance do we fall back on a default reward-to-risk multiple, and the minimum distance filter prevents the engine from nominating a target so close that the trade could never clear its risk requirement.

The visual layer

//+------------------------------------------------------------------+
//| Dashboard                                                        |
//+------------------------------------------------------------------+
void DrawPanel()
  {
   if(!InpShowPanel)
     {
      ObjectsDeleteAll(0, PFX + "PN", -1, -1);
      return;
     }

   int x = InpPanelX;
   int y = InpPanelY;
   int rowH = 15;
   int rows = 22;
   int w = 330;
   int h = rows * rowH + 16;

   string bg = PFX + "PNbg";
   if(ObjectFind(0, bg) < 0)
      ObjectCreate(0, bg, OBJ_RECTANGLE_LABEL, 0, 0, 0);
   ObjectSetInteger(0, bg, OBJPROP_CORNER, CORNER_LEFT_UPPER);
   ObjectSetInteger(0, bg, OBJPROP_XDISTANCE, x - 6);
   ObjectSetInteger(0, bg, OBJPROP_YDISTANCE, y - 8);
   ObjectSetInteger(0, bg, OBJPROP_XSIZE, w);
   ObjectSetInteger(0, bg, OBJPROP_YSIZE, h);
   ObjectSetInteger(0, bg, OBJPROP_BGCOLOR, InpColPanelBg);
   ObjectSetInteger(0, bg, OBJPROP_BORDER_TYPE, BORDER_FLAT);
   ObjectSetInteger(0, bg, OBJPROP_COLOR, clrDimGray);
   ObjectSetInteger(0, bg, OBJPROP_BACK, false);
   ObjectSetInteger(0, bg, OBJPROP_SELECTABLE, false);
   ObjectSetInteger(0, bg, OBJPROP_HIDDEN, true);

   int r = 0;
   color dirCol = (g_intent.direction > 0 ? InpColBull :
                   (g_intent.direction < 0 ? InpColBear : clrSilver));

   MakeLabel(PFX + "PN" + IntegerToString(r), x, y + rowH * r,
             "      MARKET INTENT ENGINE", clrWhite, 9);
   r++;
   MakeLabel(PFX + "PN" + IntegerToString(r), x, y + rowH * r,
             "--------------------------------------", clrDimGray, 8);
   r++;

   MakeLabel(PFX + "PN" + IntegerToString(r), x, y + rowH * r,
             Pad("Symbol", 15) + _Symbol + "  " + TFName(InpSetupTF),
             InpColPanelText, 8);
   r++;

   MakeLabel(PFX + "PN" + IntegerToString(r), x, y + rowH * r,
             Pad("Macro " + TFName(InpMacroTF), 15) + TrendName(g_stMacro.trend),
             (g_stMacro.trend > 0 ? InpColBull : (g_stMacro.trend < 0 ? InpColBear : clrSilver)),
             8);
   r++;

   MakeLabel(PFX + "PN" + IntegerToString(r), x, y + rowH * r,
             Pad("Primary " + TFName(InpPrimaryTF), 15) + TrendName(g_stPrim.trend),
             (g_stPrim.trend > 0 ? InpColBull : (g_stPrim.trend < 0 ? InpColBear : clrSilver)),
             8);
   r++;

   string strTxt = "n/a";
   if(g_stPrim.trend > 0)
      strTxt = "HH -> HL";
   else
      if(g_stPrim.trend < 0)
         strTxt = "LL -> LH";
   MakeLabel(PFX + "PN" + IntegerToString(r), x, y + rowH * r,
             Pad("Structure", 15) + strTxt, InpColPanelText, 8);
   r++;

   string liqTxt = "none fresh";
   color  liqCol = clrSilver;
   if(g_sweep.valid)
     {
      liqTxt = (g_sweep.side > 0 ? "BUY-SIDE SWEPT" : "SELL-SIDE SWEPT");
      liqTxt += " (" + g_sweep.tag + ")";
      liqCol = (g_sweep.side > 0 ? InpColBear : InpColBull);
     }
   MakeLabel(PFX + "PN" + IntegerToString(r), x, y + rowH * r,
             Pad("Liquidity", 15) + liqTxt, liqCol, 8);
   r++;

   string dispTxt = "none";
   color  dispCol = clrSilver;
   if(g_beh.dispValid)
     {
      dispTxt = (g_beh.dispDir > 0 ? "BULLISH " : "BEARISH ");
      dispTxt += (g_beh.dispScore > 0.75 ? "STRONG" : (g_beh.dispScore > 0.5 ? "MODERATE" : "MILD"));
      dispCol = (g_beh.dispDir > 0 ? InpColBull : InpColBear);
     }
   MakeLabel(PFX + "PN" + IntegerToString(r), x, y + rowH * r,
             Pad("Displacement", 15) + dispTxt, dispCol, 8);
   r++;

   int fB = FindFVG(1), fS = FindFVG(-1);
   string fvgTxt = "none";
   if(fB >= 0 && fS >= 0)
      fvgTxt = "BULL + BEAR";
   else
      if(fB >= 0)
         fvgTxt = StringFormat("BULLISH (%.0f%% filled)", g_fvgs[fB].fillRatio * 100.0);
      else
         if(fS >= 0)
            fvgTxt = StringFormat("BEARISH (%.0f%% filled)", g_fvgs[fS].fillRatio * 100.0);
   MakeLabel(PFX + "PN" + IntegerToString(r), x, y + rowH * r,
             Pad("FVG", 15) + fvgTxt, InpColPanelText, 8);
   r++;

   string bosTxt = "none";
   color  bosCol = clrSilver;
   if(g_stSetup.lastBreakDir != 0)
     {
      bosTxt = (g_stSetup.lastBreakDir > 0 ? "BULLISH " : "BEARISH ");
      bosTxt += (g_stSetup.lastBreakChoch ? "CHOCH" : "BOS");
      bosCol = (g_stSetup.lastBreakDir > 0 ? InpColBull : InpColBear);
     }
   MakeLabel(PFX + "PN" + IntegerToString(r), x, y + rowH * r,
             Pad("LTF structure", 15) + bosTxt, bosCol, 8);
   r++;

   MakeLabel(PFX + "PN" + IntegerToString(r), x, y + rowH * r,
             "--------------------------------------", clrDimGray, 8);
   r++;

   MakeLabel(PFX + "PN" + IntegerToString(r), x, y + rowH * r,
             Pad("Structure", 14) + BarGauge(g_intent.cStructure, 10), dirCol, 8);
   r++;
   MakeLabel(PFX + "PN" + IntegerToString(r), x, y + rowH * r,
             Pad("Liquidity", 14) + BarGauge(g_intent.cLiquidity, 10), dirCol, 8);
   r++;
   MakeLabel(PFX + "PN" + IntegerToString(r), x, y + rowH * r,
             Pad("Displacement", 14) + BarGauge(g_intent.cDisplacement, 10), dirCol, 8);
   r++;
   MakeLabel(PFX + "PN" + IntegerToString(r), x, y + rowH * r,
             Pad("Momentum", 14) + BarGauge(g_intent.cMomentum, 10), dirCol, 8);
   r++;
   MakeLabel(PFX + "PN" + IntegerToString(r), x, y + rowH * r,
             Pad("Location", 14) + BarGauge(g_intent.cLocation, 10), dirCol, 8);
   r++;

   MakeLabel(PFX + "PN" + IntegerToString(r), x, y + rowH * r,
             "--------------------------------------", clrDimGray, 8);
   r++;

   MakeLabel(PFX + "PN" + IntegerToString(r), x, y + rowH * r,
             StringFormat("INTENT  %s  %.0f / 100  [%s]",
                          (g_intent.direction > 0 ? "BULLISH" :
                           (g_intent.direction < 0 ? "BEARISH" : "NEUTRAL")),
                          g_intent.score, g_intent.headline),
             dirCol, 9);
   r++;

   MakeLabel(PFX + "PN" + IntegerToString(r), x, y + rowH * r,
             "STATE   " + StateName(g_intent.state),
             (g_intent.state == STATE_ACTION_LONG ? InpColBull :
              (g_intent.state == STATE_ACTION_SHORT ? InpColBear : clrSilver)), 9);
   r++;

   if(g_plan.valid)
     {
      MakeLabel(PFX + "PN" + IntegerToString(r), x, y + rowH * r,
                StringFormat("Zone  %s - %s  (%s)",
                             PriceS(g_plan.zoneLow), PriceS(g_plan.zoneHigh), g_plan.note),
                InpColPanelText, 8);
      r++;
      MakeLabel(PFX + "PN" + IntegerToString(r), x, y + rowH * r,
                StringFormat("SL %s  TP1 %s  RR %.2f",
                             PriceS(g_plan.sl), PriceS(g_plan.tp1), g_plan.rr),
                InpColPanelText, 8);
      r++;
     }
   else
     {
      MakeLabel(PFX + "PN" + IntegerToString(r), x, y + rowH * r,
                "Zone  -", InpColPanelText, 8);
      r++;
      MakeLabel(PFX + "PN" + IntegerToString(r), x, y + rowH * r,
                "SL -  TP1 -  RR -", InpColPanelText, 8);
      r++;
     }

   MakeLabel(PFX + "PN" + IntegerToString(r), x, y + rowH * r,
             "AutoTrade  " + (InpAutoTrade ? "ON (executing)" : "OFF (visual only)"),
             (InpAutoTrade ? clrOrange : clrSilver), 8);
   r++;

   for(int i = r; i < rows; i++)
      MakeLabel(PFX + "PN" + IntegerToString(i), x, y + rowH * i, "", clrBlack, 8);
  }

The visual layer is deliberately thin, because the analysis has already done the work of deciding what deserves to be on screen. Swing labels are drawn only for the primary timeframe and only for the most recent handful, since a chart carrying every swing from the last six hundred bars is the problem we set out to solve rather than a feature. Structural breaks are drawn as a line running from the broken swing to the candle that broke it, solid for a BOS and dashed for a CHOCH, so that the eye can trace what was broken and when without reading any text at all.

Liquidity pools are drawn with their tag and touch count, and swept pools switch to a dotted grey line rather than disappearing. Keeping the swept levels visible is a small decision with a large effect on readability, because the sequence of which pools have already been taken is a large part of the story a trader is trying to read. A chart that silently deletes them leaves us wondering why price turned where it did.

Execution and risk

//+------------------------------------------------------------------+
//|  Calculate Lots                                                  |
//+------------------------------------------------------------------+
double CalcLots(double entry, double sl)
  {
   double minLot  = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MIN);
   double maxLot  = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MAX);
   double lotStep = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_STEP);

   if(InpUseFixedLot)
     {
      double f = MathMax(minLot, MathMin(maxLot, InpFixedLot));
      return NormalizeDouble(MathFloor(f / lotStep) * lotStep, 2);
     }

   double tickValue = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_VALUE);
   double tickSize  = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_SIZE);
   double slDist    = MathAbs(entry - sl);

   if(tickValue <= 0.0 || tickSize <= 0.0 || slDist <= 0.0)
      return minLot;

   double riskMoney  = AccountInfoDouble(ACCOUNT_BALANCE) * InpRiskPercent / 100.0;
   double lossPerLot = (slDist / tickSize) * tickValue;
   if(lossPerLot <= 0.0)
      return minLot;

   double lots = riskMoney / lossPerLot;
   lots = MathFloor(lots / lotStep) * lotStep;
   lots = MathMax(minLot, MathMin(maxLot, lots));
   return NormalizeDouble(lots, 2);
  }

The execution layer is short, and it is short because by the time it runs every decision has already been made elsewhere. Position size derives from the distance to the invalidation level and the configured risk percentage, computed through tick value and tick size rather than through points, so that the same code sizes correctly on gold, on indices, and on currency pairs without a lookup table. The floor operation on the lot step is a small detail worth keeping, since rounding up rather than down would quietly push every trade slightly over the intended risk.

//+------------------------------------------------------------------+
//|  Expire Armed Setups                                             |
//+------------------------------------------------------------------+
void ExpireArmedSetup()
  {
   if(!g_arm.armed)
      return;

   g_arm.barsLeft--;
   if(g_arm.barsLeft <= 0)
     {
      g_arm.armed = false;
      if(InpEntryMode == ENTRY_LIMIT_ZONE)
         DeletePendingOrders();
      if(InpDebugPrint)
         Print("[MIE] armed setup expired");
     }
  }

Three entry modes are available, and they represent a genuine trade-off rather than a menu of equivalent options. Market on signal takes the trade the moment ACTION appears, which guarantees participation and accepts a worse average price. Market on zone arms the setup and waits for price to trade back into the entry band, which improves the price and accepts that some setups will simply run away. A limit on a zone places a pending order in the band and removes it if the setup goes stale. Whichever mode is chosen, the armed setup carries an expiry measured in setup-timeframe bars, because a plan built around a sweep that happened two hours ago has stopped being a plan and become a hope.


Demo

We are showing a demonstration rather than an equity curve, and the reason is that the two would be answering different questions. A tester run measures the compound of everything at once: the intent score, the entry mode, the stop buffer, the trailing rule, the partial close, the spread, and the modeling quality. A weak result would leave us unable to say whether the market reading was wrong or whether the trade management around a sound reading was, and a strong one would flatter the analysis for reasons that might have nothing to do with it.

The demonstration below runs with InpAutoTrade = false, so nothing is sent to the broker, and the whole of what you see is the analysis layer talking. Watch the panel first: the score moves continuously rather than flipping, because each component contributes a graded amount, and a setup that loses its displacement or drifts from discount into premium will visibly bleed points rather than switching off. Follow a full sequence if you can. A liquidity pool starts as a solid line; price reaches it; the line turns dotted gray when taken. The sweep marker prints its side, excursion (points), and +DISP/+BOS tags. Only then does the state move from WAIT to WATCH to ACTION, and the entry zone, invalidation, and targets appear.



Conclusion

Across this article, we built a system that refuses to stop at annotation. We detected swing structure with an explicit confirmation lag, so that the chart the engine draws over history is the chart it could actually have drawn at the time. We classified higher highs, higher lows, lower highs, and lower lows, and we separated breaks of structure from changes of character using nothing more than a memory of the prevailing trend, which made it structurally impossible for the two to be reported together. Not only that, but we captured protected levels at the one instant they were unambiguous and later used them as stop-loss anchors. We built a liquidity map from swings, equal-level clusters and previous session extremes, merged on an ATR-scaled tolerance so that the same defaults behave sensibly across instruments.

Further more, we defined a sweep as a five-part sequence rather than a touch, and graded every one by the reaction that followed it. Not only that, but we measured displacement jointly on range and body, tracked fair value gaps by fill ratio rather than by a binary flag, and fused all of it into two competing weighted totals that resolve through a conflict penalty. Finally, we reduce the score to five decision states. We attach a plan whose entry, invalidation, and targets are drawn from objects the analysis has already found. The system is switchable between a decision-support indicator and a fully automated Expert Advisor via a single boolean.

After reading this work, you will have a working template for turning a discretionary reading into a measurable process. The specific weights and thresholds in this build are a starting point and should be treated as one; they encode our opinions about what matters, and a trader who disagrees can change them from the inputs panel rather than from the source. The durable part is the architecture underneath. Because each stage answers one question and writes into its own structures, any component can be examined, replaced, or switched off without rewriting the surrounding system. The scoring function can be retuned without touching the structure engine. The liquidity map can gain session highs and lows without disturbing the plan builder. That separation is what makes the engine something to build on rather than something to run as-is.

Attached files |
Market_Intent.mq5 (84.04 KB)
Combining 3D Bars, Quantum Computing, and Machine Learning into a Unified Trading System Combining 3D Bars, Quantum Computing, and Machine Learning into a Unified Trading System
The article presents the full integration of the 3D-bar module into a quantum-enhanced trading system for forecasting the movement of currency pairs. The system combines stationary four-dimensional features, an 8-qubit quantum encoder, and CatBoost gradient boosting with 52+ features. The system is implemented in Python using MetaTrader 5, Qiskit, CatBoost, and optional integration with the Llama 3.2 LLM for interpreting forecasts.
Building Your Personal Expert Advisor (Part 2): Risk Management and Dynamic Lot Sizing Building Your Personal Expert Advisor (Part 2): Risk Management and Dynamic Lot Sizing
This part implements risk-based position sizing for the EA. Lot size is derived from account balance, a chosen risk percent, and ATR-based stop distance, then confined and rounded to the broker's volume rules and minimum stop levels. An optional drawdown-aware layer reduces risk during equity declines. Readers get a reproducible sizing function that keeps per-trade risk consistent and orders acceptable to the server.
Neural Networks in Trading: Disentangling Structured Components (Conclusion) Neural Networks in Trading: Disentangling Structured Components (Conclusion)
The article provides a detailed explanation of the SCNN architecture and one way to implement it using MQL5. We will show how time series decomposition can be combined with neural network methods and attention mechanisms.
Market Simulation: Position View (XII) Market Simulation: Position View (XII)
In this article, you will learn how to create a visual signal on your trading platform so you can determine directly on the chart whether a position is long or short, without having to open the Terminal. In addition, the article also explains how to implement a feature that improves the display when moving Take Profit and Stop Loss lines by hiding the horizontal line that follows the mouse cursor while these lines are being moved, to avoid confusion. The article provides practical insight into setting up market simulation systems.