Trading

Automating Classic Market Methods in MQL5 (Part 1): Wyckoff Accumulation and Distribution - MQL5 Articles

June 4, 202637 min read
RoboForex

Running robots on virtual hosting is easyFollow our step-by-step MetaTrader VPS guide for beginnersRead

MetaTrader 5 / Trading

Introduction

One of the most enduring frameworks in technical analysis is the Wyckoff method. Richard Wyckoff developed it in the 1930s after decades of observing how large institutional operators accumulate and distribute positions in financial markets.

The method describes market movement not as random noise, but as a series of cause-and-effect cycles driven by institutional supply and demand. These cycles leave identifiable structural footprints—the spring, the sign of strength, the upthrust, and the sign of weakness—that a prepared trader can detect and trade.

Although widely discussed, the Wyckoff method is rarely automated in MQL5. This is not because individual events are difficult to detect; for example, a close below support is trivial.

The challenge is that each Wyckoff event only carries meaning in the context of the events that preceded it. A spring is not merely a dip below support.

It is a dip below support that occurs within a defined range, following a prior downtrend, confirmed by a specific volume signature. Detecting it in isolation is meaningless.

Detecting it as part of a validated sequence is everything.

In this article, we build a complete Expert Advisor that automates this sequential detection using a finite state machine. The EA identifies accumulation structures on the H4 timeframe, detects the spring shakeout, confirms demand with a sign of strength, and enters long at the last point of support. For distribution, it mirrors the same logic in reverse: upthrust, a sign of weakness, and a short entry at the last point of supply. We will cover the following topics:

  1. Understanding the Wyckoff framework
  2. Why tick volume works as an institutional proxy
  3. The state machine architecture
  4. Implementation in MQL5
  5. Backtesting
  6. Conclusion

By the end, you will have a fully functional MQL5 Expert Advisor that detects and trades Wyckoff structures automatically.

Understanding the Wyckoff Framework

The Wyckoff method describes four recurring market cycles: accumulation, markup, distribution, and markdown. Accumulation occurs when institutions quietly build long positions in a sideways range following a downtrend.

Markup follows as price rises on institutional demand. Distribution occurs when institutions sell into an uptrend, creating a sideways range at the top.

Markdown follows as price falls on institutional supply.

This EA focuses on the two transition points—the end of Accumulation and the end of Distribution—because these offer the best-defined, highest-probability setups in the entire Wyckoff cycle.

The Accumulation Sequence

Accumulation moves through five phases. In Phase A, a selling climax on high volume stops the downtrend.

An automatic rally then defines the top of the range. A secondary test on lower volume confirms that selling pressure has dried up.

The second phase sees institutions quietly accumulate within the range as price oscillates between support and resistance. The third phase delivers the spring—a deliberate false breakdown below range support on lower-than-average volume, designed to flush out weak holders and collect liquidity before the markup begins.

The fourth phase confirms institutional demand with a sign of strength: a high-volume close above range resistance. The last point of support is the pullback that follows the sign of strength—the final low-risk entry before phase E begins and price leaves the range.

Fig. 1. Chart showing spring, sign of strength, and "LPS" entry.

The Distribution Mirror

Distribution reverses every element. The upthrust is the false breakout above range resistance on low volume—the institutional bull trap at the top.

The sign of weakness is the high-volume breakdown below range support. The last point of supply is the low-volume rally after the sign of weakness—the optimal short entry before "markdown" begins.

Fig. 2. Chart showing upthrust, sign of weakness, and "LPSY" entry.

What the EA Detects

The EA does not attempt to label every Wyckoff event from preliminary support to phase E. Full phase detection on live data requires subjective judgment that is difficult to codify reliably. Instead, it focuses on the events with the clearest, most objective definitions: range formation following a directional move, the spring or upthrust as the terminal shakeout, the sign of strength (SOS) or sign of weakness (SOW) as confirmation, and the last point of support (LPS) or the last point of supply (LPSY) as the entry trigger.

Why Tick Volume Works as an Institutional Proxy

Volume is central to every Wyckoff detection decision. This raises a practical question for MetaTrader 5 forex traders: real exchange volume is not available for currency pairs. MetaTrader 5 provides tick volume—the number of price changes per bar.

Tick volume is a valid proxy for trading activity. 85) with real traded volume on major forex pairs.

When institutions are active, prices change frequently. When the market is quiet, ticks are sparse.

The relative volume patterns that Wyckoff described—climactic volume on reversals, low volume on tests, and expanding volume on breakouts—manifest clearly in tick volume data.

The critical point is that we always work with relative volume, not absolute counts. A selling climax does not require a specific tick number.

It requires tick volume substantially above the average for that instrument on that timeframe. Every volume check in the EA computes a rolling average over the bars within the current range and compares each bar as a ratio against that baseline.

This approach is instrument-agnostic and works identically on EURUSD, gold, and any other symbols in MetaTrader 5.

The State Machine Architecture

Before writing the first line of code, the most important design decision must be made: the EA will use a finite state machine to track where the market is in the Wyckoff sequence. This is the only reliable architecture for implementing a sequential pattern detector.

Consider the naive alternative—an independent check in OnTick:

text
1if(iLow(_Symbol, PERIOD_CURRENT, 1) < supportLevel)
2   OpenLong();  // WRONG: fires on any dip, regardless of context

This fires on any dip below any support level, with no regard for whether a range exists, whether a prior downtrend preceded it, or whether volume was appropriate. It is not a Wyckoff spring detector. It is a noise generator.

A state machine solves this by ensuring the EA is always in exactly one state and can only advance to the next state when the specific structural evidence for that state is present. The spring cannot be evaluated before the range is confirmed.

The sign of strength cannot be evaluated before the spring is confirmed. The entry cannot execute before the sign of strength is confirmed.

The sequence is enforced mechanically—it cannot be skipped.

The complete state flow for accumulation is as follows: "STATE_IDLE" → "STATE_RANGE_FORMING" → "STATE_SPRING_DETECTED" → "STATE_SOS_CONFIRMED" → "STATE_IN_TRADE." While the complete state flow for accumulation is as follows: "STATE_IDLE" → "STATE_RANGE_FORMING" → "STATE_UPTHRUST_DETECTED" → "STATE_SOW_CONFIRMED" → "STATE_IN_TRADE." Any state can reset to "STATE_IDLE" if the structure is invalidated—for example, if the price closes below the spring low after the spring is detected, the accumulation thesis is abandoned and the EA starts scanning for a fresh structure.

Implementation in MQL5

To create the program in MQL5, open MetaEditor, navigate to the Experts folder, click "New," and follow the prompts to create the file. We will build the EA piece by piece, explaining each section as we go.

Includes and Input Parameters

We begin by including the standard trade library and defining the EA's state machine enumeration and all input parameters.

cpp
1//+------------------------------------------------------------------+
2//|                                                    WyckoffEA.mq5 |
3//|                                Copyright 2026, Tola Moses Hector |
4//|                                          https://t.me/tolahector |
5//+------------------------------------------------------------------+
6#property copyright "Copyright 2026, Tola Moses Hector"
7#property link      "https://t.me/tolahector"
8#property version   "1.00"
9#property description "Wyckoff Accumulation and Distribution EA"
10#property description "Detects Spring + SOS for long entries (LPS)"
11#property description "Detects Upthrust + SOW for short entries (LPSY)"
12#property description "H4 timeframe recommended"
13
14#include <Trade\Trade.mqh>
15
16//+------------------------------------------------------------------+
17//| EA State Machine                                                 |
18//+------------------------------------------------------------------+
19enum ENUM_WYCKOFF_STATE
20  {
21   STATE_IDLE,               // No structure active
22   STATE_RANGE_FORMING,      // Valid range identified — locked, watching for Spring/UT
23   STATE_SPRING_DETECTED,    // Spring confirmed, watching for SOS
24   STATE_SOS_CONFIRMED,      // SOS confirmed, watching for LPS entry
25   STATE_UPTHRUST_DETECTED,  // Upthrust confirmed, watching for SOW
26   STATE_SOW_CONFIRMED,      // SOW confirmed, watching for LPSY entry
27   STATE_IN_TRADE            // Position open
28  };
29
30//+------------------------------------------------------------------+
31//| Input Parameters                                                 |
32//+------------------------------------------------------------------+
33input group "=== Range Detection ==="
34input int    InpTrendBars       = 15;    // Bars of prior trend required
35input int    InpMinRangeBars    = 10;    // Minimum bars to form range
36input int    InpMaxRangeBars    = 60;    // Maximum bars to scan for range
37input double InpMinRangePips    = 20.0;  // Minimum range height (pips)
38input double InpMaxRangePips    = 400.0; // Maximum range height (pips)
39input double InpSpringTolerance = 10.0;  // Pips below support for Spring
40input int    InpRangeWatchBars  = 30;    // Max bars to watch range before reset
41
42input group "=== Volume Settings ==="
43input double InpHighVolMult     = 1.2;   // High-volume multiplier (SOS/SOW)
44input double InpLowVolMult      = 1.2;   // Low-volume multiplier (Spring/Upthrust)
45
46input group "=== Entry and Risk ==="
47input double InpRiskPercent     = 1.0;   // Risk per trade (% of balance)
48input double InpRR              = 2.0;   // Risk-reward ratio
49input int    InpATRPeriod       = 14;    // ATR period for stop distance
50input double InpATRMult         = 1.5;   // ATR multiplier for stop distance
51input int    InpLPSBars         = 8;     // Bars to wait for LPS/LPSY pullback
52
53input group "=== General ==="
54input int    InpMagicNumber     = 777001; // Magic number
55input int    InpSlippage        = 10;     // Slippage in points
56input bool   InpShowLabels      = true;   // Draw event labels on chart

mqh," which provides the "CTrade" class needed for order execution and position management. Furthermore, we define the "ENUM_WYCKOFF_STATE" enumeration with seven states that represent every possible position in the Wyckoff detection sequence.

This enumeration is the backbone of the entire EA—every detection decision flows from the current state. The input parameters are organized into four groups using the "input group" directive.

The "Range Detection" group controls how the EA identifies a valid trading range and prior trend. The "Volume Settings" group defines the relative volume thresholds used for every Wyckoff event.

The "Entry and Risk" group controls position sizing and stop placement. All parameters are configurable from the properties window without code changes.

Global Variables and Range Structure

Next, we define the data structure that stores all information about the current Wyckoff structure and the global variables that manage EA state.

text
1//+------------------------------------------------------------------+
2//| Wyckoff Range Data Structure                                     |
3//+------------------------------------------------------------------+
4struct SWyckoffRange
5  {
6   double            support;        // Range support level
7   double            resistance;     // Range resistance level
8   int               start_bar;      // Bars back where range started
9   double            avg_volume;     // Average tick volume within range
10   bool              bullish_bias;   // true = accumulation, false = distribution
11   double            spring_low;     // Low of the Spring bar
12   double            sos_high;       // High of the SOS bar
13   double            upthrust_high;  // High of the Upthrust bar
14   double            sow_low;        // Low of the SOW bar
15  };
16
17//+------------------------------------------------------------------+
18//| Global Variables                                                 |
19//+------------------------------------------------------------------+
20ENUM_WYCKOFF_STATE g_state          = STATE_IDLE;         // Current EA state
21SWyckoffRange      g_range;                               // Current range data
22CTrade             g_trade;                               // Trade execution object
23int                g_atr_handle     = INVALID_HANDLE;     // ATR indicator handle
24datetime           g_last_bar       = 0;                  // Last processed bar time
25int                g_lps_count      = 0;                  // Bars waited after SOS
26int                g_lpsy_count     = 0;                  // Bars waited after SOW
27int                g_range_watch    = 0;                  // Bars watched in RANGE_FORMING

The "SWyckoffRange" struct serves as the EA's memory of the current structure. It stores the range boundaries, the volume baseline computed from within the range, the directional bias determined by whether a downtrend or uptrend preceded the range, and the price levels of each detected event.

Keeping all structure data in one place makes the code readable and eliminates scattered global variables. The global section instantiates "g_trade" from "CTrade" for all trade operations and creates "g_atr_handle" for the ATR indicator used in stop distance calculation.

Utility Functions

Before building the detection logic, we define several utility functions used throughout the EA.

sql
1//+------------------------------------------------------------------+
2//| Returns pip size for the current symbol                          |
3//+------------------------------------------------------------------+
4double PipSize()
5  {
6   int digits = (int)SymbolInfoInteger(_Symbol, SYMBOL_DIGITS); // Get symbol digits
7   return (digits == 3 || digits == 5) ? _Point * 10.0 : _Point; // Return pip size
8  }
9
10//+------------------------------------------------------------------+
11//| Calculates lot size from risk percent and stop distance          |
12//+------------------------------------------------------------------+
13double CalcLots(double sl_pips)
14  {
15   double balance    = AccountInfoDouble(ACCOUNT_BALANCE);                    // Get balance
16   double risk_money = balance * InpRiskPercent / 100.0;                      // Monetary risk
17   double tick_val   = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_VALUE);    // Tick value
18   double tick_size  = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_SIZE);     // Tick size
19   double pip_size   = PipSize();                                             // Get pip size
20   if(tick_size <= 0 || tick_val <= 0 || sl_pips <= 0)
21      return 0;                                                               // Validate inputs
22   double pip_value  = (pip_size / tick_size) * tick_val;                     // Pip monetary value
23   double lots       = risk_money / (sl_pips * pip_value);                    // Raw lot size
24   double step       = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_STEP);         // Volume step
25   double min_lot    = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MIN);          // Minimum lot
26   double max_lot    = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MAX);          // Maximum lot
27   lots = MathFloor(lots / step) * step;                                      // Normalize to step
28   return MathMax(min_lot, MathMin(max_lot, lots));                           // Clamp to limits
29  }
30
31//+------------------------------------------------------------------+
32//| Checks if EA has an open position on this symbol                 |
33//+------------------------------------------------------------------+
34bool PositionOpen()
35  {
36   for(int i = PositionsTotal() - 1; i >= 0; i--)                             // Iterate positions
37     {
38      ulong ticket = PositionGetTicket(i);                                    // Get ticket
39      if(!PositionSelectByTicket(ticket))
40         continue;                                                            // Select position
41      if(PositionGetString(POSITION_SYMBOL)  != _Symbol)
42         continue;                                                            // Check symbol
43      if(PositionGetInteger(POSITION_MAGIC)  != InpMagicNumber)
44         continue;                                                            // Check magic
45      return true;                                                            // Position found
46     }
47   return false;                                                              // No position
48  }

"PipSize()" detects whether the symbol has three or five decimal digits and returns the correct pip size in price units. This makes every subsequent distance calculation instrument-agnostic.

"CalcLots()" computes position size from a monetary risk target using "SYMBOL_TRADE_TICK_VALUE" and "SYMBOL_TRADE_TICK_SIZE"—the broker-provided values that convert price distance to account currency correctly for any instrument, including gold and indexes. This is more accurate than simple pip multiplication, which fails on nonstandard instruments.

"PositionOpen()" scans all open positions and returns true if any belong to this EA on the current symbol, using both the symbol name and magic number to avoid confusion with manual trades.

Chart Drawing Functions

Chart labels are important for verifying that the EA is detecting events correctly during testing. We define simple drawing helpers.

text
1//+------------------------------------------------------------------+
2//| Draws a horizontal line at the specified price level             |
3//+------------------------------------------------------------------+
4void DrawHLine(string name, double price, color clr, ENUM_LINE_STYLE style)
5  {
6   if(!InpShowLabels)
7      return;                                                                // Check flag
8   string obj = "WYK_" + name;                                               // Build object name
9   ObjectDelete(0, obj);                                                     // Remove existing
10   ObjectCreate(0, obj, OBJ_HLINE, 0, 0, price);                             // Create hline
11   ObjectSetInteger(0, obj, OBJPROP_COLOR, clr);                             // Set color
12   ObjectSetInteger(0, obj, OBJPROP_STYLE, style);                           // Set style
13   ObjectSetInteger(0, obj, OBJPROP_WIDTH, 1);                               // Set width
14   ChartRedraw(0);                                                           // Redraw chart
15  }
16
17//+------------------------------------------------------------------+
18//| Places a text label at the specified time and price              |
19//+------------------------------------------------------------------+
20void DrawLabel(string name, datetime time, double price, string text, color clr)
21  {
22   if(!InpShowLabels)
23      return;                                                                // Check flag
24   string obj = "WYK_" + name;                                               // Build object name
25   ObjectDelete(0, obj);                                                     // Remove existing
26   ObjectCreate(0, obj, OBJ_TEXT, 0, time, price);                           // Create text object
27   ObjectSetString(0, obj, OBJPROP_TEXT, text);                              // Set text content
28   ObjectSetInteger(0, obj, OBJPROP_COLOR, clr);                             // Set color
29   ObjectSetInteger(0, obj, OBJPROP_FONTSIZE, 9);                            // Set font size
30   ChartRedraw(0);                                                           // Redraw chart
31  }
32
33//+------------------------------------------------------------------+
34//| Removes all chart objects created by this EA                     |
35//+------------------------------------------------------------------+
36void ClearLabels()
37  {
38   int total = ObjectsTotal(0);                                              // Get object count
39   for(int i = total - 1; i >= 0; i--)                                       // Iterate backwards
40     {
41      string name = ObjectName(0, i);                                        // Get object name
42      if(StringFind(name, "WYK_") == 0)
43         ObjectDelete(0, name);                                              // Delete if EA's
44     }
45   ChartRedraw(0);                                                           // Redraw chart
46  }

"DrawHLine()" places or updates a horizontal line at the range support and resistance levels. Prefixing all object names with "WYK_" allows "ClearLabels()" to reliably remove only this EA's objects without touching any manually placed lines or indicators. "DrawLabel()" places event name tags—"SPRING," "SOS," "UPTHRUST," "SOW," "LPS," and "LPSY"—directly on the chart at the price and time of each detected event.

Prior Trend Detection

A trading range only carries Wyckoff significance if it follows a directional move. We check this before accepting any range as valid.

text
1//+------------------------------------------------------------------+
2//| Returns true if a downtrend preceded the specified bar           |
3//+------------------------------------------------------------------+
4bool HasPriorDowntrend(int from_bar)
5  {
6   double high_buf[], low_buf[];                                             // Price buffers
7   ArraySetAsSeries(high_buf, true);                                         // Set as series
8   ArraySetAsSeries(low_buf,  true);                                         // Set as series
9   if(CopyHigh(_Symbol, PERIOD_CURRENT, from_bar, InpTrendBars, high_buf) < InpTrendBars)
10      return false;                                                          // Copy highs
11   if(CopyLow(_Symbol, PERIOD_CURRENT, from_bar, InpTrendBars, low_buf)  < InpTrendBars)
12      return false;                                                          // Copy lows
13   double first_high = 0, second_low = DBL_MAX;                              // Init comparators
14   int half = InpTrendBars / 2;                                              // Split midpoint
15   for(int i = 0; i < half; i++)
16      first_high = MathMax(first_high, high_buf[i + half]);                  // Find early high
17   for(int i = 0; i < half; i++)
18      second_low = MathMin(second_low, low_buf[i]);                          // Find recent low
19   return first_high > second_low + PipSize() * InpMinRangePips * 0.3;       // Confirm descent
20  }
21
22//+------------------------------------------------------------------+
23//| Returns true if an uptrend preceded the specified bar            |
24//+------------------------------------------------------------------+
25bool HasPriorUptrend(int from_bar)
26  {
27   double high_buf[], low_buf[];                                              // Price buffers
28   ArraySetAsSeries(high_buf, true);                                          // Set as series
29   ArraySetAsSeries(low_buf,  true);                                          // Set as series
30   if(CopyHigh(_Symbol, PERIOD_CURRENT, from_bar, InpTrendBars, high_buf) < InpTrendBars)
31      return false;                                                          // Copy highs
32   if(CopyLow(_Symbol, PERIOD_CURRENT, from_bar, InpTrendBars, low_buf)  < InpTrendBars)
33      return false;                                                          // Copy lows
34   double first_low = DBL_MAX, second_high = 0;                              // Init comparators
35   int half = InpTrendBars / 2;                                              // Split midpoint
36   for(int i = 0; i < half; i++)
37      first_low   = MathMin(first_low,   low_buf[i + half]);                 // Find early low
38   for(int i = 0; i < half; i++)
39      second_high = MathMax(second_high, high_buf[i]);                       // Find recent high
40   return second_high > first_low + PipSize() * InpMinRangePips * 0.3;       // Confirm ascent
41  }

Both functions split the "InpTrendBars" lookback period in half and compare the price structure of the earlier half against the recent half. For "HasPriorDowntrend()", the highest high should be in the earlier period and the lowest low in the recent period—confirming that the price moved downward over the lookback.

"HasPriorUptrend()" applies the mirror logic. The minimum distance threshold prevents a flat, trendless period from being misclassified as a trend.

Range Detection

With prior trend detection in place, we can now identify valid trading ranges. The function scans from the minimum required bar count upward, returning the first range that satisfies all conditions rather than always expanding to the maximum. This prevents the state machine from being anchored to an overly wide, unfocused range.

When both a prior downtrend and a prior uptrend are detected before the same range, the EA resolves the ambiguity by comparing the first and last close of the trend period immediately before the range. If the price fell into the range, the preceding move was a downtrend, and the bias is accumulation. If the price rose into the range, the preceding move was an uptrend, and the bias is distribution.

text
1//+------------------------------------------------------------------+
2//| Scans recent bars once to identify a valid Wyckoff range         |
3//+------------------------------------------------------------------+
4bool DetectRange()
5  {
6   double high_buf[], low_buf[];                                             // Price buffers
7   long   vol_buf[];                                                         // Volume buffer
8   ArraySetAsSeries(high_buf, true);                                         // Set as series
9   ArraySetAsSeries(low_buf,  true);                                         // Set as series
10   ArraySetAsSeries(vol_buf,  true);                                         // Set as series
11   int bars = InpMaxRangeBars + InpTrendBars + 5;                            // Total bars needed
12   if(CopyHigh(_Symbol, PERIOD_CURRENT, 1, bars, high_buf)     < bars)
13      return false;                                                          // Copy highs
14   if(CopyLow(_Symbol, PERIOD_CURRENT, 1, bars, low_buf)      < bars)
15      return false;                                                          // Copy lows
16   if(CopyTickVolume(_Symbol, PERIOD_CURRENT, 1, bars, vol_buf) < bars)
17      return false;                                                          // Copy volumes
18   double pip   = PipSize();                                                 // Get pip size
19   double min_h = InpMinRangePips * pip;                                     // Min height in price
20   double max_h = InpMaxRangePips * pip;                                     // Max height in price
21//--- Try each possible range length from minimum to maximum
22   for(int range_bars = InpMinRangeBars; range_bars <= InpMaxRangeBars; range_bars++)   // Try lengths
23     {
24      double rh = 0, rl = DBL_MAX;                                           // Init bounds
25      for(int i = 0; i < range_bars; i++)                                    // Scan range bars
26        {
27         rh = MathMax(rh, high_buf[i]);                                      // Update range high
28         rl = MathMin(rl, low_buf[i]);                                       // Update range low
29        }
30      double height = rh - rl;                                               // Compute height
31      if(height < min_h)
32         continue;                                                           // Too narrow, try longer
33      if(height > max_h)
34         break;                                                              // Too wide, stop
35      //--- Check that all bars stay within 25% tolerance of range height
36      double tol  = height * 0.25;                                           // Tolerance band
37      bool   fits = true;                                                    // Fit flag
38      for(int i = 0; i < range_bars; i++)                                    // Check each bar
39        {
40         if(high_buf[i] > rh + tol || low_buf[i] < rl - tol)                 // Bar outside range
41           {
42            fits = false;                                                    // Mark as not fitting
43            break;                                                           // Stop checking
44           }
45        }
46      if(!fits)
47         continue;                                                           // Skip this length
48      //--- Confirm a prior trend before the range
49      bool down_before = HasPriorDowntrend(range_bars + 1);                  // Check downtrend
50      bool up_before   = HasPriorUptrend(range_bars + 1);                    // Check uptrend
51      if(!down_before && !up_before)
52         continue;                                                           // No prior trend
53      //--- Compute average volume within the range
54      double avg_vol = 0;                                                    // Volume sum
55      for(int i = 0; i < range_bars; i++)
56         avg_vol += (double)vol_buf[i];                                      // Accumulate
57      avg_vol /= range_bars;                                                 // Compute average
58      //--- Populate the range struct
59      g_range.support       = rl;                                            // Store support
60      g_range.resistance    = rh;                                            // Store resistance
61      g_range.start_bar     = range_bars;                                    // Store bar count
62      g_range.avg_volume    = avg_vol;                                       // Store average vol
63      //--- When both trends detected, pick the more dominant one
64      if(down_before && up_before)
65        {
66         //--- Measure which directional move was larger immediately before range
67         double high_buf[], low_buf[];
68         ArraySetAsSeries(high_buf, true);
69         ArraySetAsSeries(low_buf,  true);
70         int trend_bars = InpTrendBars;
71         CopyHigh(_Symbol, PERIOD_CURRENT, range_bars + 1, trend_bars, high_buf);
72         CopyLow(_Symbol, PERIOD_CURRENT, range_bars + 1, trend_bars, low_buf);
73         double trend_high = 0, trend_low = DBL_MAX;
74         for(int k = 0; k < trend_bars; k++)
75           {
76            trend_high = MathMax(trend_high, high_buf[k]);
77            trend_low  = MathMin(trend_low,  low_buf[k]);
78           }
79         double first_close  = iClose(_Symbol, PERIOD_CURRENT, range_bars + trend_bars);
80         double last_close   = iClose(_Symbol, PERIOD_CURRENT, range_bars + 1);
81         //--- If price fell into the range = downtrend before = accumulation
82         //--- If price rose into the range = uptrend before = distribution
83         g_range.bullish_bias = (last_close < first_close);
84        }
85      else
86         g_range.bullish_bias = down_before;                                 // Set Bias
87      g_range.spring_low    = 0;                                             // Clear spring low
88      g_range.sos_high      = 0;                                             // Clear SOS high
89      g_range.upthrust_high = 0;                                             // Clear upthrust
90      g_range.sow_low       = 0;                                             // Clear SOW low
91      DrawHLine("SUPPORT",    g_range.support,    clrGreen, STYLE_DASH);     // Draw support
92      DrawHLine("RESISTANCE", g_range.resistance, clrRed,   STYLE_DASH);     // Draw resistance
93      Print(StringFormat("WyckoffEA: Range locked | Bias: %s | S: %.5f | R: %.5f | Bars: %d | AvgVol: %.0f",
94                         g_range.bullish_bias ? "ACCUMULATION" : "DISTRIBUTION",
95                         g_range.support, g_range.resistance, range_bars, avg_vol));     // Log result
96      return true;                                                           // Range valid
97     }
98   return false;                                                             // No range found
99  }

"DetectRange()" seeds each candidate range from the minimum bar count and expands upward. The 25% tolerance band accommodates the natural variability of real Wyckoff ranges without accepting wildly expanded zones.

bullish_bias" accordingly. The average volume is then computed from the range bars—this becomes the baseline for every subsequent volume comparison.

Note that we copy from bar 1, not bar 0, to avoid acting on incomplete bar data.

Spring and Upthrust Detection

With a confirmed range and its volume baseline, we watch for the terminal shakeout event that signals the end of phase B.

text
1//+------------------------------------------------------------------+
2//| Checks the last closed bar for a valid Spring                    |
3//+------------------------------------------------------------------+
4bool CheckSpring()
5  {
6   double low1   = iLow(_Symbol, PERIOD_CURRENT, 1);                         // Last bar low
7   double close1 = iClose(_Symbol, PERIOD_CURRENT, 1);                       // Last bar close
8   double pip    = PipSize();                                                 // Get pip size
9   double thresh = g_range.support - InpSpringTolerance * pip;               // Spring threshold
10   if(low1 < thresh && close1 > g_range.support)                             // Penetrate and recover
11     {
12      long vol1 = iTickVolume(_Symbol, PERIOD_CURRENT, 1);                   // Last bar volume
13      if((double)vol1 < g_range.avg_volume * InpLowVolMult)                  // Below volume threshold
14        {
15         g_range.spring_low = low1;                                          // Store Spring low
16         datetime t1 = iTime(_Symbol, PERIOD_CURRENT, 1);                    // Get bar time
17         DrawLabel("SPRING", t1, low1 - pip * 5, "SPRING", clrLime);         // Draw label
18         Print(StringFormat("WyckoffEA: SPRING | Low: %.5f | Close: %.5f | Vol: %I64d | AvgVol: %.0f",
19                            low1, close1, vol1, g_range.avg_volume));                     // Log event
20         return true;                                                        // Spring confirmed
21        }
22     }
23   return false;                                                              // Not a Spring
24  }
25
26//+------------------------------------------------------------------+
27//| Checks the last closed bar for a valid Upthrust                  |
28//+------------------------------------------------------------------+
29bool CheckUpthrust()
30  {
31   double high1  = iHigh(_Symbol, PERIOD_CURRENT, 1);                        // Last bar high
32   double close1 = iClose(_Symbol, PERIOD_CURRENT, 1);                       // Last bar close
33   double pip    = PipSize();                                                // Get pip size
34   double thresh = g_range.resistance + InpSpringTolerance * pip;            // Upthrust threshold
35   if(high1 > thresh && close1 < g_range.resistance)                         // Penetrate and reverse
36     {
37      long vol1 = iTickVolume(_Symbol, PERIOD_CURRENT, 1);                   // Last bar volume
38      if((double)vol1 < g_range.avg_volume * InpLowVolMult)                  // Below volume threshold
39        {
40         g_range.upthrust_high = high1;                                      // Store Upthrust high
41         datetime t1 = iTime(_Symbol, PERIOD_CURRENT, 1);                    // Get bar time
42         DrawLabel("UT", t1, high1 + pip * 5, "UPTHRUST", clrOrangeRed);     // Draw label
43         Print(StringFormat("WyckoffEA: UPTHRUST | High: %.5f | Close: %.5f | Vol: %I64d | AvgVol: %.0f",
44                            high1, close1, vol1, g_range.avg_volume));                    // Log event
45         return true;                                                         // Upthrust confirmed
46        }
47     }
48   return false;                                                              // Not an Upthrust
49  }

"CheckSpring()" evaluates three conditions simultaneously on the last closed bar. The low must penetrate below the spring threshold—the support level minus the configured tolerance.

The close must recover back above support within the same bar. The tick volume must be below the low-volume multiplier times the range average—confirming absorption rather than aggressive selling.

All three must be true for the spring to be valid. spring_low" provides an invalidation level: if a subsequent bar closes below this level, the accumulation thesis is abandoned.

"CheckUpthrust()" applies the identical logic in reverse at range resistance.

Sign of Strength and Sign of Weakness

The spring and upthrust are necessary but not sufficient. We need confirmation that institutional demand or supply has actually taken control.

text
1//+------------------------------------------------------------------+
2//| Checks the last closed bar for a Sign of Strength                |
3//+------------------------------------------------------------------+
4bool CheckSOS()
5  {
6   double close1 = iClose(_Symbol, PERIOD_CURRENT, 1);                       // Last bar close
7   double high1  = iHigh(_Symbol, PERIOD_CURRENT, 1);                        // Last bar high
8   if(close1 > g_range.resistance)                                           // Close above resistance
9     {
10      long vol1 = iTickVolume(_Symbol, PERIOD_CURRENT, 1);                   // Last bar volume
11      if((double)vol1 > g_range.avg_volume * InpHighVolMult)                 // Above volume threshold
12        {
13         g_range.sos_high = high1;                                           // Store SOS high
14         datetime t1 = iTime(_Symbol, PERIOD_CURRENT, 1);                    // Get bar time
15         DrawLabel("SOS", t1, high1 + PipSize() * 5, "SOS", clrDodgerBlue); // Draw label
16         Print(StringFormat("WyckoffEA: SOS | Close: %.5f | Vol: %I64d | AvgVol: %.0f",
17                            close1, vol1, g_range.avg_volume));              // Log event
18         return true;                                                        // SOS confirmed
19        }
20     }
21   return false;                                                              // Not a SOS
22  }
23
24//+------------------------------------------------------------------+
25//| Checks the last closed bar for a Sign of Weakness                |
26//+------------------------------------------------------------------+
27bool CheckSOW()
28  {
29   double close1 = iClose(_Symbol, PERIOD_CURRENT, 1);                       // Last bar close
30   double low1   = iLow(_Symbol, PERIOD_CURRENT, 1);                         // Last bar low
31   if(close1 < g_range.support)                                              // Close below support
32     {
33      long vol1 = iTickVolume(_Symbol, PERIOD_CURRENT, 1);                   // Last bar volume
34      if((double)vol1 > g_range.avg_volume * InpHighVolMult)                 // Above volume threshold
35        {
36         g_range.sow_low = low1;                                             // Store SOW low
37         datetime t1 = iTime(_Symbol, PERIOD_CURRENT, 1);                    // Get bar time
38         DrawLabel("SOW", t1, low1 - PipSize() * 5, "SOW", clrCrimson);      // Draw label
39         Print(StringFormat("WyckoffEA: SOW | Close: %.5f | Vol: %I64d | AvgVol: %.0f",
40                            close1, vol1, g_range.avg_volume));              // Log event
41         return true;                                                        // SOW confirmed
42        }
43     }
44   return false;                                                              // Not a SOW
45  }

The volume logic for the sign of strength and sign of weakness is the direct opposite of the spring and upthrust. Where the spring required low volume to confirm absorption, the sign of strength requires high volume to confirm institutional demand breaking price out of the range.

A breakout above resistance on weak volume is unconvincing—institutions are not driving it. 2 times the range average carries structural conviction.

"CheckSOW()" applies the identical requirement for the distribution breakdown.

Entry Logic: LPS and LPSY

After a sign of strength or a sign of weakness confirmation, the EA waits for a pullback before entering. Chasing a breakout bar produces poor risk-reward. The last point of support and the last point of supply—the pullbacks after the sign of strength and sign of weakness—provide the optimal entries.

text
1//+------------------------------------------------------------------+
2//| Waits for LPS pullback and opens long position                   |
3//+------------------------------------------------------------------+
4void CheckLPSEntry()
5  {
6   g_lps_count++;                                                            // Increment wait counter
7   if(g_lps_count > InpLPSBars)                                              // Wait window expired
8     {
9      Print("WyckoffEA: LPS window expired — resetting.");                   // Log expiry
10      g_state = STATE_IDLE;                                                  // Reset state
11      ClearLabels();                                                         // Clear chart
12      return;                                                                // Exit function
13     }
14   double close1 = iClose(_Symbol, PERIOD_CURRENT, 1);                       // Last bar close
15   long   vol1   = iTickVolume(_Symbol, PERIOD_CURRENT, 1);                  // Last bar volume
16   bool pulled_back = (close1 < g_range.sos_high && close1 > g_range.support); // Pullback check
17   bool low_vol     = ((double)vol1 < g_range.avg_volume * InpHighVolMult);     // Volume check
18   Print(StringFormat("WyckoffEA: LPS check %d/%d | Close: %.5f | PulledBack: %s | LowVol: %s",
19                      g_lps_count, InpLPSBars, close1,
20                      pulled_back ? "YES" : "NO", low_vol ? "YES" : "NO"));  // Log check
21   if(pulled_back && low_vol)                                                // LPS conditions met
22     {
23      double atr_buf[];                                                      // ATR buffer
24      ArraySetAsSeries(atr_buf, true);                                       // Set as series
25      if(CopyBuffer(g_atr_handle, 0, 1, 1, atr_buf) < 1)
26         return;                                                             // Copy ATR value
27      double atr    = atr_buf[0];                                            // ATR value
28      double ask    = SymbolInfoDouble(_Symbol, SYMBOL_ASK);                 // Current ask
29      double sl     = ask - atr * InpATRMult;                                // Stop loss price
30      double sl_pip = (ask - sl) / PipSize();                                // Stop in pips
31      double tp     = ask + sl_pip * InpRR * PipSize();                      // Take profit price
32      double lots   = CalcLots(sl_pip);                                      // Calculate lot size
33      if(lots <= 0)
34         return;                                                             // Invalid lot size
35      long   stop_lv  = SymbolInfoInteger(_Symbol, SYMBOL_TRADE_STOPS_LEVEL); // Broker stop level
36      double min_dist = stop_lv * _Point;                                    // Minimum distance
37      if(ask - sl < min_dist)
38         sl = ask - min_dist - _Point;                                       // Adjust SL if needed
39      if(tp - ask < min_dist)
40         tp = ask + min_dist + _Point;                                       // Adjust TP if needed
41      if(g_trade.Buy(lots, _Symbol, ask, sl, tp, "Wyckoff LPS Long"))        // Open long
42        {
43         datetime t1 = iTime(_Symbol, PERIOD_CURRENT, 1);                    // Get bar time
44         DrawLabel("LPS", t1, iLow(_Symbol, PERIOD_CURRENT, 1) - PipSize() * 3, "LPS", clrGold); // Draw label
45         Print(StringFormat("WyckoffEA: LONG opened | Lots: %.2f | Ask: %.5f | SL: %.5f | TP: %.5f",
46                            lots, ask, sl, tp));                                          // Log trade
47         g_state = STATE_IN_TRADE;                                           // Update state
48        }
49     }
50  }
51
52//+------------------------------------------------------------------+
53//| Waits for LPSY rally and opens short position                    |
54//+------------------------------------------------------------------+
55void CheckLPSYEntry()
56  {
57   g_lpsy_count++;                                                           // Increment wait counter
58   if(g_lpsy_count > InpLPSBars)                                             // Wait window expired
59     {
60      Print("WyckoffEA: LPSY window expired — resetting.");                  // Log expiry
61      g_state = STATE_IDLE;                                                  // Reset state
62      ClearLabels();                                                         // Clear chart
63      return;                                                                // Exit function
64     }
65   double close1 = iClose(_Symbol, PERIOD_CURRENT, 1);                       // Last bar close
66   long   vol1   = iTickVolume(_Symbol, PERIOD_CURRENT, 1);                  // Last bar volume
67   bool rallied = (close1 > g_range.sow_low && close1 < g_range.resistance); // Rally check
68   bool low_vol = ((double)vol1 < g_range.avg_volume * InpHighVolMult);      // Volume check
69   Print(StringFormat("WyckoffEA: LPSY check %d/%d | Close: %.5f | Rallied: %s | LowVol: %s",
70                      g_lpsy_count, InpLPSBars, close1,
71                      rallied ? "YES" : "NO", low_vol ? "YES" : "NO"));      // Log check
72   if(rallied && low_vol)                                                    // LPSY conditions met
73     {
74      double atr_buf[];                                                      // ATR buffer
75      ArraySetAsSeries(atr_buf, true);                                       // Set as series
76      if(CopyBuffer(g_atr_handle, 0, 1, 1, atr_buf) < 1)
77         return;                                                             // Copy ATR value
78      double atr    = atr_buf[0];                                            // ATR value
79      double bid    = SymbolInfoDouble(_Symbol, SYMBOL_BID);                 // Current bid
80      double sl     = bid + atr * InpATRMult;                                // Stop loss price
81      double sl_pip = (sl - bid) / PipSize();                                // Stop in pips
82      double tp     = bid - sl_pip * InpRR * PipSize();                      // Take profit price
83      double lots   = CalcLots(sl_pip);                                      // Calculate lot size
84      if(lots <= 0)
85         return;                                                             // Invalid lot size
86      long   stop_lv  = SymbolInfoInteger(_Symbol, SYMBOL_TRADE_STOPS_LEVEL); // Broker stop level
87      double min_dist = stop_lv * _Point;                                    // Minimum distance
88      if(sl - bid < min_dist)
89         sl = bid + min_dist + _Point;                                       // Adjust SL if needed
90      if(bid - tp < min_dist)
91         tp = bid - min_dist - _Point;                                       // Adjust TP if needed
92      if(g_trade.Sell(lots, _Symbol, bid, sl, tp, "Wyckoff LPSY Short"))     // Open short
93        {
94         datetime t1 = iTime(_Symbol, PERIOD_CURRENT, 1);                    // Get bar time
95         DrawLabel("LPSY", t1, iHigh(_Symbol, PERIOD_CURRENT, 1) + PipSize() * 3, "LPSY", clrGold); // Draw label
96         Print(StringFormat("WyckoffEA: SHORT opened | Lots: %.2f | Bid: %.5f | SL: %.5f | TP: %.5f",
97                            lots, bid, sl, tp));                                          // Log trade
98         g_state = STATE_IN_TRADE;                                           // Update state
99        }
100     }
101  }

Both entry functions increment a bar counter each time they are called. If the pullback does not materialize within "InpLPSBars" bars, the state machine resets to "STATE_IDLE," and the structure is abandoned—the EA never chases a trade.

5 ATR below the entry for longs and above for shorts, making it proportional to current market volatility rather than a fixed pip distance. The take profit is set at the configured risk-reward ratio.

The broker's "SYMBOL_TRADE_STOPS_LEVEL" is checked before execution, and the stop and take profit are adjusted if they fall within the broker's minimum distance requirement.

OnInit, OnDeinit, and OnTick

With all detection and entry functions defined, the event handlers bring the EA to life.

cpp
1//+------------------------------------------------------------------+
2//| Expert initialization function                                   |
3//+------------------------------------------------------------------+
4int OnInit()
5  {
6   g_atr_handle = iATR(_Symbol, PERIOD_CURRENT, InpATRPeriod);               // Create ATR handle
7   if(g_atr_handle == INVALID_HANDLE)                                        // Check handle
8     {
9      Print("WyckoffEA: ATR indicator handle creation failed.");             // Log error
10      return INIT_FAILED;                                                    // Return failure
11     }
12   g_trade.SetExpertMagicNumber(InpMagicNumber);                             // Set magic number
13   g_trade.SetDeviationInPoints(InpSlippage);                                // Set slippage
14   g_state       = STATE_IDLE;                                               // Initialize state
15   g_lps_count   = 0;                                                        // Initialize LPS counter
16   g_lpsy_count  = 0;                                                        // Initialize LPSY counter
17   g_range_watch = 0;                                                        // Initialize watch counter
18   g_last_bar    = 0;                                                        // Initialize bar time
19   Print("WyckoffEA initialized | Symbol: ", _Symbol,
20         " | TF: ", EnumToString(Period()),
21         " | Magic: ", InpMagicNumber);                                      // Log initialization
22   return INIT_SUCCEEDED;                                                    // Return success
23  }
24
25//+------------------------------------------------------------------+
26//| Expert deinitialization function                                 |
27//+------------------------------------------------------------------+
28void OnDeinit(const int reason)
29  {
30   IndicatorRelease(g_atr_handle);                                            // Release ATR handle
31   ClearLabels();                                                             // Remove chart objects
32  }
33
34//+------------------------------------------------------------------+
35//| Expert tick function                                             |
36//+------------------------------------------------------------------+
37void OnTick()
38  {
39   datetime current_bar = iTime(_Symbol, PERIOD_CURRENT, 0);                 // Current bar open time
40   if(current_bar == g_last_bar)
41      return;                                                                // Skip if same bar
42   g_last_bar = current_bar;                                                 // Update last bar time
43   if(g_state == STATE_IN_TRADE)                                             // In trade state
44     {
45      if(!PositionOpen())                                                    // Position has closed
46        {
47         Print("WyckoffEA: Trade closed — returning to IDLE.");              // Log closure
48         g_state       = STATE_IDLE;                                         // Reset state
49         g_lps_count   = 0;                                                  // Reset LPS counter
50         g_lpsy_count  = 0;                                                  // Reset LPSY counter
51         g_range_watch = 0;                                                  // Reset watch counter
52         ClearLabels();                                                      // Clear chart
53        }
54      return;                                                                // Exit tick
55     }
56   switch(g_state)                                                           // State machine switch
57     {
58      case STATE_IDLE:                                                       // Idle state
59         if(DetectRange())                                                   // Range found
60           {
61            g_state       = STATE_RANGE_FORMING;                             // Advance state
62            g_range_watch = 0;                                               // Reset watch counter
63           }
64         break;                                                              // End case
65      case STATE_RANGE_FORMING:                                              // Range locked — watch only
66         g_range_watch++;                                                    // Increment watch counter
67         if(g_range_watch > InpRangeWatchBars)                               // Range stale
68           {
69            Print("WyckoffEA: Range watch expired — returning to IDLE.");    // Log expiry
70            g_state = STATE_IDLE;                                            // Reset state
71            ClearLabels();                                                   // Clear chart
72            break;                                                           // End case
73           }
74         if(g_range.bullish_bias)                                            // Accumulation bias
75           {
76            if(CheckSpring())
77               g_state = STATE_SPRING_DETECTED;                              // Spring found
78           }
79         else                                                                // Distribution bias
80           {
81            if(CheckUpthrust())
82               g_state = STATE_UPTHRUST_DETECTED;                            // Upthrust found
83           }
84         break;                                                              // End case
85      case STATE_SPRING_DETECTED:                                            // Spring detected
86         if(CheckSOS())                                                      // SOS found
87           {
88            g_state     = STATE_SOS_CONFIRMED;                               // Advance state
89            g_lps_count = 0;                                                 // Reset LPS counter
90           }
91         else
92            if(iClose(_Symbol, PERIOD_CURRENT, 1) < g_range.spring_low)      // Spring failed
93              {
94               Print("WyckoffEA: Spring invalidated — returning to IDLE.");  // Log failure
95               g_state = STATE_IDLE;                                         // Reset state
96               ClearLabels();                                                // Clear chart
97              }
98         break;                                                              // End case
99      case STATE_SOS_CONFIRMED:                                              // SOS confirmed
100         CheckLPSEntry();                                                    // Check LPS entry
101         break;                                                              // End case
102      case STATE_UPTHRUST_DETECTED:                                          // Upthrust detected
103         if(CheckSOW())                                                      // SOW found
104           {
105            g_state      = STATE_SOW_CONFIRMED;                              // Advance state
106            g_lpsy_count = 0;                                                // Reset LPSY counter
107           }
108         else
109            if(iClose(_Symbol, PERIOD_CURRENT, 1) > g_range.upthrust_high)    // Upthrust failed
110              {
111               Print("WyckoffEA: Upthrust invalidated — returning to IDLE.");  // Log failure
112               g_state = STATE_IDLE;                                           // Reset state
113               ClearLabels();                                                  // Clear chart
114              }
115         break;                                                                // End case
116      case STATE_SOW_CONFIRMED:                                                // SOW confirmed
117         CheckLPSYEntry();                                                     // Check LPSY entry
118         break;                                                                // End case
119     }
120  }

"OnInit()" creates the ATR indicator handle and returns "INIT_FAILED" immediately if creation fails, preventing the EA from running without its stop distance calculator. "OnDeinit()" releases the indicator handle and cleans up all chart objects.

"OnTick()" uses a new-bar gate—comparing the current bar's open time against the stored "g_last_bar"—so all detection logic runs exactly once per closed bar rather than on every tick. The switch statement makes the state machine readable at a glance: each case has one responsibility, each transition has one trigger, and the code cannot reach any detection function without passing through all preceding states.

Backtesting

To test the EA, open the MetaTrader 5 Strategy Tester and use these settings: Symbol = EURUSD; Timeframe = H4; Modeling = Every tick based on real ticks; Initial deposit = $10,000; Period = 2022.01.01–2024.12.31. Use the following inputs: "InpTrendBars" = 15; "InpMinRangeBars" = 15; "InpMaxRangeBars" = 60; "InpMinRangePips" = 20; "InpMaxRangePips" = 400; "InpSpringTolerance" = 10; "InpHighVolMult" = 1.2; "InpLowVolMult" = 1.2; "InpRiskPercent" = 1.0; "InpRR" = 2.0; "InpATRPeriod" = 14; "InpATRMult" = 1.5; and "InpLPSBars" = 8.

What to Expect

Wyckoff structures are not common. On EURUSD H4, expect between four and ten qualifying setups per year.

This is correct behavior—the EA does not generate signals constantly. It waits for the full sequential evidence to accumulate before committing.

When it does enter, the structural backing is complete: confirmed range, confirmed spring or upthrust, confirmed sign of strength or sign of weakness, and a pullback entry.

Trade duration will be bimodal: many short trades stopped at the initial stop during failed structures, and fewer but significantly longer winners that capture the markup or markdown phase after a complete accumulation or distribution. The average winner should be substantially larger than the average loser. If the profit factor is consistently below 1.0, increase "InpHighVolMult" to require a stronger sign of strength and a sign of weakness confirmation.

Test Results

Demonstration on EURUSD H4.

Fig. 3. Visual demonstration of detection and entry.

Fig. 4. Test results: equity and balance curve.

Fig. 5. Test results.

Fig. 6. Test results, entries.

Known Limitations

Wyckoff structures require prior trend context. In genuinely sideways, trendless markets, the range detector may identify false structures. Increase "InpMinRangePips" and "InpTrendBars" if too many low-quality ranges are detected on a particular instrument.

Tick volume is a proxy, not real volume. The relative volume patterns are reliable.

Absolute threshold values may need adjustment per broker. Run initial tests and observe the Journal tab for the volume diagnostics printed by each detection function.

The EA tracks one structure at a time. If a spring is invalidated because the price closes below the spring low, the state machine resets to "STATE_IDLE" and begins scanning for a new range. This is correct Wyckoff behavior—a failed spring changes the structural interpretation.

The "LPS" and "LPSY" entry window is fixed. After a sign of strength or a sign of weakness confirmation, the EA waits up to "InpLPSBars" bars for the pullback.

In strong markup or markdown moves, price may not pull back within this window. The EA resets rather than chasing the move.

This is conservative behavior by design.

The code is a demonstration of the Wyckoff concept in MQL5. Before live deployment, adjust volume multipliers to your symbol's typical tick volume behavior and run a minimum 24-month backtest on a demo account.

Conclusion

Wyckoff spent decades studying the same market behavior that modern traders call "Smart Money Concepts," "institutional footprints," and "liquidity engineering." His framework remains relevant because it describes market structure in terms of cause and effect—not patterns and indicators—and cause and effect does not go out of date.

The challenge of automating Wyckoff is not detecting individual events. Any EA can detect a close below support.

The challenge is ensuring that each event only means something in the context of the events that preceded it. That context is what the state machine in this article enforces.

A spring is not just a false breakdown. It is a false breakdown that occurs within a defined range, after a prior downtrend, on lower-than-average volume.

Remove any of those conditions, and the signal is not a Wyckoff spring—it is just a dip. The state machine makes this impossible to bypass.

Every function introduced in this article has a single job. Furthermore, every state transition has a single trigger.

Every volume check uses a relative ratio computed from the current structure, not a hard-coded number. The result is an EA that adapts to different instruments and volatility regimes while remaining anchored to the structural logic Wyckoff described.

The EA does not trade often. When it does, the full weight of a confirmed Wyckoff sequence is behind the entry.

All code was compiled and tested in MetaTrader 5. mq5 source file is attached to this article.

mq5 to MQL5\\Experts\\ and compile in MetaEditor with no additional dependencies. Recommended for EURUSD on H4.

Always test on a demo account before live deployment.

Attached files |

Download ZIP

WyckoffEA.mq5(41.36 KB)

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.

Tola Moses Hector

I started as a trader, analyzing charts, price action, and market structure long before writing my first line of MQL code. That trading foundation shapes every Expert Advisor and indicator I build.

Today, I develop professional-grade Expert Advisors and indicators for MetaTrader, with a focus on robustness, clarity, and real-world usability. My work emphasizes clean architecture, risk-aware logic, and long-term maintainability rather than over-optimized or curve-fit systems.

Beyond trading software, I also develop full-stack solutions, including front-end and back-end web applications, API integrations, and server deployments. I work with cloud infrastructure such as Microsoft Azure to support scalable, secure, and high-performance trading and data systems.

My goal is simple: built well-engineered, professional tools that traders can understand, trust, and confidently use in live market environments.

Go to discussion

MetaTrader 5 Machine Learning Blueprint (Part 17): CPCV Backtesting — From Python Model to Tick-Level Evidence

We bridge Python-native artifacts to MQL5 for tick-accurate CPCV backtesting. The export script converts the ONNX model, calibrator, feature spec, and path masks to flat files, while the expert advisor rebuilds features, performs ONNX inference with calibration, and trades on real ticks. The Strategy Tester runs each combinatorial path, and Python aggregates per-path equities into a path Sharpe distribution to assess robustness after spread, slippage, and commission.

Seasonality Indicator by Hours, Days of the Week, and Days of the Month

The article explains how to develop a tool for analyzing recurring price patterns in financial markets — by day of the month (1-31), day of the week (Monday-Sunday), or hour of the day (0-23). The indicator analyzes historical data, calculates the average return for each period, and displays the results as a histogram with a forecast. It includes customizable parameters: seasonality type, number of bars analyzed, display as percentages or absolute values, chart colors.

MQL5 Trading Tools (Part 34): Replacing Native Chart Objects with an Interactive Canvas Drawing Layer

We replace native MetaTrader chart objects with a canvas-based drawing engine that renders tools pixel-by-pixel on a full-chart bitmap layer. The article implements persistent object storage with per-tool style memory, precise hit testing, selection, whole-object dragging, and handle manipulation. It also adds new line tools, a reorganized category system with a one-click delete action, and a rubber-band preview for multi-click placement.

Backtracking Search Algorithm (BSA)

What if an optimization algorithm could remember its past journeys and use that memory to find better solutions? BSA does just that – balancing exploration with revisiting the tried and true.

In this article, we reveal the secrets of the algorithm. A simple idea, minimum parameters and a stable result.

[

\ MetaTrader 5 for iOS and Android\

\

Fully featured platform for any devices and web browsers\

\

Learn more](https://www.mql5.com/ff/go?link=https://trade.metatrader5.com/&a=ddonqpipxfqlnsvzlwuowsuwlejpyjxk&s=9daba65b69f40afc3c35f95b1f84ef5824d68c47f29ce96a6dc5b164a2727baa&uid=&ref=https://www.mql5.com/en/articles/22628&id=wdausxxqrpvhekbwjrjlhqjghyhesrqqau&fz_uniq=6462449802444343718)

This website uses cookies. Learn more about our Cookies Policy.

You are missing trading opportunities:

  • Free trading apps
  • Over 8,000 signals for copying
  • Economic news for exploring financial markets

RegistrationLog in

latin characters without spaces

a password will be sent to this email

An error occurred

You agree to website policy and terms of use

If you do not have an account, please register

Allow the use of cookies to log in to the MQL5.com website.

Please enable the necessary setting in your browser, otherwise you will not be able to log in.

Forgot your login/password?

RoboForex