preview
Automating Classic Market Methods in MQL5 (Part 2): Wyckoff Cause and Effect—Point and Figure Price Targets

Automating Classic Market Methods in MQL5 (Part 2): Wyckoff Cause and Effect—Point and Figure Price Targets

MetaTrader 5Examples |
123 0
Tola Moses Hector
Tola Moses Hector

Introduction

In Part 1, we built an Expert Advisor that detects Wyckoff accumulation and distribution with a finite state machine. It confirms the spring→SOS sequence for longs and the upthrust→SOW sequence for shorts, then enters at the last point of support or supply.

That EA answered the question of where to enter. This article answers the companion question that Wyckoff considered equally important: where to exit. The answer comes from his second law—cause and effect—and the point and figure count that measures it. The EA built here is self-contained: every function needed to run it is included below, and no code from Part 1 is required.

Richard Wyckoff taught that markets move in two phases: accumulation, where large operators quietly absorb supply at low prices, and distribution, where they offload those positions at high prices. The transition from accumulation to markup, or from distribution to markdown, is not random. It is the proportional effect of a measurable cause. Wyckoff called this his second law, and he gave traders a precise tool for measuring it: the point and figure count.

This article builds a complete Expert Advisor that automates the Wyckoff trading cycle in MQL5. It detects structures with a finite state machine, confirms key events, enters at the correct structural point, and calculates take profit using a point‑and‑figure model of the range.

No external libraries are required. The complete source file is attached and requires no dependencies beyond the standard "Trade\Trade.mqh" include.

We will cover the following topics:

  1. Wyckoff Accumulation and Distribution
  2. The Law of Cause and Effect—Theory and Formula
  3. Solution Architecture
  4. Implementation in MQL5
  5. Key Implementation Choices
  6. Backtesting
  7. Known Limitations
  8. Conclusion


Wyckoff Accumulation and Distribution

Wyckoff identified two structural patterns that precede major price moves: accumulation before a markup phase, and distribution before a markdown phase. Both follow a recognizable sequence of events. Understanding this sequence is necessary to understand how the EA makes its decisions.

The Accumulation Sequence

Accumulation forms after a downtrend. Large operators—Wyckoff called them the Composite Man—begin absorbing supply from retail sellers. The structure has five phases, but the EA focuses on the specific events that confirm it.

The spring is the first key event. The price penetrates support briefly—dipping below the range low—and then recovers back inside the range on the same bar. This move flushes out weak longs and tests whether genuine supply remains. A spring with relatively low tick volume suggests that selling pressure is exhausted.

The sign of strength follows the spring. The price closes above resistance on high tick volume, demonstrating that demand is now in control and the range is being broken to the upside.

The last point of support is the entry trigger. After the sign of strength, the price pulls back toward the resistance level—which has now become support—on low volume. This pullback is the final retest before the markup begins. The EA enters long at the last point of support.

The Distribution Mirror

Distribution is the mirror of accumulation. It forms after an uptrend, as large operators distribute their holdings to retail buyers.

The upthrust is the key event corresponding to the spring. The price penetrates resistance briefly and then reverses back inside the range on the same bar, on relatively low tick volume. The sign of weakness follows the upthrust. The price closes below support on high tick volume.

The last point of supply is the entry trigger. After the sign of weakness, the price rallies back toward the support level—now acting as resistance—on low volume. The EA enters short at the last point of supply.

Why Sequential Detection Matters

Each of these events only carries meaning in context. A close below support is not a sign of weakness unless an upthrust has already been confirmed. A pullback after a breakout is not a last point of support unless a spring and a sign of strength have already occurred in sequence. A standalone check for any one of these events, regardless of what preceded it, will produce many false signals. The finite state machine enforces the correct sequence: every event must follow the event before it. If the sequence breaks—for example, if the price closes below the spring low before a sign of strength is confirmed—the state machine resets to idle and starts over.

The Law of Cause and Effect—Theory and Formula

cause and effect buy

Figure 1. Chart showing spring, SOS, LPS, entry, and target (cause and effect).

The P&F price target formula has two versions, one for accumulation and one for distribution.

For a long target from an accumulation range:

Target = (Column Count × Box Size × Reversal Factor) + Count Line Price

For a short target from a distribution range:

Target = Count Line Price − (Column Count × Box Size × Reversal Factor)

Each term has a precise definition.

The count line is the price level at which the horizontal count is taken. For accumulation, Wyckoff specified that the count line should be drawn at the last point of support—the final low before the sign of strength. This is the level where the strongest institutional demand was demonstrated, and it is the most conservative level from which to project. For distribution, the count line is drawn at the last point of supply—the final high before the sign of weakness.

The column count is the number of P&F columns within the trading range at the count line level. A column is counted whenever the P&F chart records a reversal at or through that price level. The wider the accumulation range, the more columns it contains, and the higher the projected target.

The box size is the price increment that each X or O represents. If it is too small, minor ticks create noise; if it is too large, structure is hidden. In this article, box size is derived from the range ATR (≈0.25 ATR) to adapt to volatility across instruments.

The reversal factor is the number of boxes required to register a new column. The most conservative approach, and the one that produces the highest column count and therefore the most optimistic target, is a 1-box reversal. For intraday and swing trading on forex pairs, a 1-box reversal is the standard choice. A 3-box reversal is more common for longer-term analysis and produces more conservative targets by generating fewer columns for the same price movement.

This article uses a 1-box reversal. It provides the most complete representation of the oscillations within the trading range and therefore the most accurate column count.

A worked example with the same EURUSD parameters from Part 1:

The range identified by the EA spans from 1.0680 (support) to 1.0820 (resistance). ATR during the range is 22 pips. Box size: 0.25 × 22 pips = 5.5 pips, rounded to 5 pips for clean arithmetic. The count line, taken at the last point of support, is 1.0700. The P&F representation of the range produces 18 columns at the 1.0700 level. The reversal factor is 1.

Long target = (18 × 5 pips × 1) + 1.0700 = 1.0700 + 90 pips = 1.0790

This projects the markup phase to reach 1.0790. If the actual markup takes the price beyond 1.0820 (the top of the range), the target is likely conservative—which is exactly as Wyckoff intended. He taught traders to always use the most conservative count first.

Solution Architecture

Before writing any code, it helps to see how the pieces connect. The EA operates as a pipeline: each stage produces output that the next stage consumes.

OHLC bars and tick volume
        ↓
  DetectRange()
  Identifies support, resistance, bias, average volume, and range ATR
        ↓
  CheckSpring() or CheckUpthrust()
  Confirms the structural entry event on low volume
        ↓
  CheckSOS() or CheckSOW()
  Confirms the directional breakout on high volume
        ↓
  CheckLPSEntry() or CheckLPSYEntry()
  Waits for the pullback and sets the count line
        ↓
  CalcPFTarget()
  Calls CalcRangeATR, then BuildPFChart, then CountColumnsAtLevel
  Returns the projected price or 0 for the fallback
        ↓
  CTrade.Buy() or CTrade.Sell()
  Opens the position with the structural take profit or the 2R fallback

The state machine controls which stage is active at any given bar. It advances only when the current stage's conditions are confirmed, and resets to idle if an invalidation condition is triggered.

State Machine States

The state machine moves through seven states. "STATE_IDLE" means no structure is active, and the EA is scanning for a range. "STATE_RANGE_FORMING" means the range is locked, and the EA is watching for a spring or an upthrust. "STATE_SPRING_DETECTED" means the spring is confirmed, and the EA is watching for the sign of strength. "STATE_SOS_CONFIRMEDmeans the sign of strength is confirmed, and the EA is waiting for the last point of support pullback. "STATE_UPTHRUST_DETECTED" means the upthrust is confirmed, and the EA is watching for the sign of weakness. "STATE_SOW_CONFIRMED" means the sign of weakness is confirmed, and the EA is waiting for the last point of supply rally. "STATE_IN_TRADE" means a position is open, and the EA is monitoring for its close.

Function Map

"DetectRange()" identifies a valid Wyckoff trading range from the OHLC bars. "HasPriorDowntrend()" confirms a downtrend preceded the range, giving accumulation context. "HasPriorUptrend()" confirms an uptrend preceded the range, giving distribution context. "CheckSpring()" detects a spring on the last closed bar. "CheckSOS()" detects a sign of strength on the last closed bar. "CheckUpthrust()" detects an upthrust on the last closed bar. "CheckSOW()" detects a sign of weakness on the last closed bar. "CheckLPSEntry()" waits for the last point of support pullback and opens the long position. "CheckLPSYEntry()" waits for the last point of supply rally and opens the short position. "CalcRangeATR()" computes the average "ATR" over the range bars. "BuildPFChart()" converts the OHLC bar data into a sequence of "P&F" columns. "CountColumnsAtLevel()" counts how many columns pass through the count line. "CalcPFTarget()" orchestrates the full "P&F" target calculation. "CalcLots()" sizes the position by monetary risk and stop distance. "PipSize()" returns the pip size for the current symbol.

Implementation in MQL5

Each section below presents a logical piece of the EA: the purpose of the code, the snippet itself, and an explanation of the key decisions within it.

Header, Includes, and Enumerations

The EA requires only the standard trade library. The state machine enumeration defines the seven possible states of the Wyckoff detection pipeline.

//+------------------------------------------------------------------+
//|                                        WyckoffCauseEffectEA.mq5  |
//|                                Copyright 2026, Tola Moses Hector |
//|                                          https://t.me/tolahector |
//+------------------------------------------------------------------+
#property copyright "Copyright 2026, Tola Moses Hector"
#property link      "https://t.me/tolahector"
#property version   "1.00"
#property description "Automating Classic Market Methods in MQL5 Part 2"
#property description "Wyckoff Cause and Effect — P&F Price Targets"
#property description "Extends Part 1 with structural take-profit calculation"
#property description "H4 timeframe recommended"

#include <Trade\Trade.mqh>

//+------------------------------------------------------------------+
//| State Machine                                                    |
//+------------------------------------------------------------------+
enum ENUM_WYCKOFF_STATE
  {
   STATE_IDLE,
   STATE_RANGE_FORMING,
   STATE_SPRING_DETECTED,
   STATE_SOS_CONFIRMED,
   STATE_UPTHRUST_DETECTED,
   STATE_SOW_CONFIRMED,
   STATE_IN_TRADE
  };

Input Parameters

The inputs are organized into four groups. The "Range Detection" group controls how the EA identifies a valid consolidation. The "Volume Settings" group controls the tick volume thresholds used to distinguish meaningful events from noise. The "P&F Target Settings" group controls the take profit calculation. The "Entry and Risk" group controls stop placement and position sizing.

//+------------------------------------------------------------------+
//| Input Parameters                                                 |
//+------------------------------------------------------------------+
input group "=== Range Detection ==="
input int    InpTrendBars        = 15;    // Bars of prior trend required
input int    InpMinRangeBars     = 10;    // Minimum bars to form range
input int    InpMaxRangeBars     = 60;    // Maximum bars to scan for range
input double InpMinRangePips     = 20.0;  // Minimum range height in pips
input double InpMaxRangePips     = 400.0; // Maximum range height in pips
input double InpSpringTolerance  = 10.0;  // Pips below support for Spring
input int    InpRangeWatchBars   = 30;    // Max bars to watch range before reset

input group "=== Volume Settings ==="
input double InpHighVolMult      = 1.2;   // High-volume multiplier for SOS and SOW
input double InpLowVolMult       = 1.2;   // Low-volume multiplier for Spring and Upthrust

input group "=== P&F Target Settings ==="
input int    InpPFReversalBoxes  = 1;     // P&F reversal factor (1 = most conservative)
input double InpMinTargetPips    = 30.0;  // Minimum valid target distance in pips
input double InpMaxTargetPips    = 500.0; // Maximum valid target distance in pips

input group "=== Entry and Risk ==="
input double InpRiskPercent      = 1.0;   // Risk per trade as percent of balance
input int    InpATRPeriod        = 14;    // ATR period for stop and box size
input double InpATRMult          = 1.5;   // ATR multiplier for stop distance
input int    InpLPSBars          = 8;     // Bars to wait for LPS or LPSY pullback

input group "=== General ==="
input int    InpMagicNumber      = 888001; // Magic number
input int    InpSlippage         = 10;     // Slippage in points
input bool   InpShowLabels       = true;   // Draw event labels on chart

"InpPFReversalBoxes" controls how many boxes must be exceeded before a new column is recorded. At 1, every move of one box in the opposite direction starts a new column, producing the most complete representation of range oscillation. "InpMinTargetPips" and "InpMaxTargetPips" guard against nonsensical projections: if the calculated target falls outside these bounds, the EA falls back to a 2R take profit and logs the reason.

Data Structures

Two structures hold the EA's working state. "SPFColumn" represents a single column on the "P&F" chart. "SWyckoffRange" holds all data about the currently detected trading range, including the two fields added for the "P&F" calculation: "range_atr" and "count_line."

//+------------------------------------------------------------------+
//| P&F Column Structure                                             |
//+------------------------------------------------------------------+
struct SPFColumn
  {
   bool              is_up;        // true = X column (rising), false = O column (falling)
   double            extreme;      // Furthest price reached in this column
   double            base;         // Price where this column began
  };
Three fields are sufficient to determine whether a column crosses any given price level. The count line check requires only "base" and "extreme." The "is_up" flag controls which price extreme is tracked during construction.
//+------------------------------------------------------------------+
//| Wyckoff Range Structure                                          |
//+------------------------------------------------------------------+
struct SWyckoffRange
  {
   double            support;        // Range support level
   double            resistance;     // Range resistance level
   int               start_bar;      // Bars back where range started
   double            avg_volume;     // Average tick volume within range
   bool              bullish_bias;   // true = accumulation, false = distribution
   double            spring_low;     // Low of the Spring bar
   double            sos_high;       // High of the SOS bar
   double            upthrust_high;  // High of the Upthrust bar
   double            sow_low;        // Low of the SOW bar
   double            count_line;     // P&F count line price (LPS or LPSY level)
   double            range_atr;      // ATR averaged over range bars
  };

The "count_line" is initialized to zero in "DetectRange()" and is set to the actual last point of support or last point of supply close only when entry conditions are confirmed. This ensures the count is always taken at the real pullback level, not an estimated level set earlier. The "range_atr" is populated alongside the other range fields and is used by "CalcPFTarget()" as the basis for the box size.

Global Variables and Utility Functions

//+------------------------------------------------------------------+
//| Global Variables                                                 |
//+------------------------------------------------------------------+
ENUM_WYCKOFF_STATE g_state       = STATE_IDLE;        // Current EA state
SWyckoffRange      g_range;                           // Current range data
CTrade             g_trade;                           // Trade execution object
int                g_atr_handle  = INVALID_HANDLE;    // ATR indicator handle
datetime           g_last_bar    = 0;                 // Last processed bar time
int                g_lps_count   = 0;                 // Bars waited after SOS
int                g_lpsy_count  = 0;                 // Bars waited after SOW
int                g_range_watch = 0;                 // Bars watched in range state
"PipSize()" returns the correct pip size for both 4 or 2-digit and 5 or 3-digit symbols. "CalcLots()" sizes the position using the tick value and the tick size from the symbol specification, which works correctly across forex pairs, metals, and "CFDs"—not just standard forex.
//+------------------------------------------------------------------+
//| Returns pip size for the current symbol                          |
//+------------------------------------------------------------------+
double PipSize()
  {
   int digits = (int)SymbolInfoInteger(_Symbol, SYMBOL_DIGITS); // Get symbol digits
   return (digits == 3 || digits == 5) ? _Point * 10.0 : _Point; // Return pip size
  }

//+------------------------------------------------------------------+
//| Calculates lot size from risk percent and stop distance in pips  |
//+------------------------------------------------------------------+
double CalcLots(double sl_pips)
  {
   double balance    = AccountInfoDouble(ACCOUNT_BALANCE);                  // Get balance
   double risk_money = balance * InpRiskPercent / 100.0;                    // Monetary risk
   double tick_val   = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_VALUE);  // Tick value
   double tick_size  = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_SIZE);   // Tick size
   double pip_size   = PipSize();                                           // Get pip size
   if(tick_size <= 0 || tick_val <= 0 || sl_pips <= 0)
      return 0;                                                             // Validate inputs
   double pip_value  = (pip_size / tick_size) * tick_val;                   // Pip monetary value
   double lots       = risk_money / (sl_pips * pip_value);                  // Raw lot size
   double step       = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_STEP);       // Volume step
   double min_lot    = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MIN);        // Minimum lot
   double max_lot    = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MAX);        // Maximum lot
   lots = MathFloor(lots / step) * step;                                    // Normalize to step
   return MathMax(min_lot, MathMin(max_lot, lots));                         // Clamp to limits
  }

The monetary risk calculation uses pip size divided by tick size, multiplied by tick value and lot size, rather than a fixed point value. This works correctly for "JPY" pairs, gold, indices, and other instruments where a naive pip times lots calculation gives the wrong result.

Three chart helper functions manage the visual output:

//+------------------------------------------------------------------+
//| Returns true if EA has an open position on this symbol           |
//+------------------------------------------------------------------+
bool PositionOpen()
  {
   for(int i = PositionsTotal() - 1; i >= 0; i--)                          // Iterate positions
     {
      ulong ticket = PositionGetTicket(i);                                  // Get ticket
      if(!PositionSelectByTicket(ticket))
         continue;                                                          // Select position
      if(PositionGetString(POSITION_SYMBOL)  != _Symbol)
         continue;                                                          // Check symbol
      if(PositionGetInteger(POSITION_MAGIC)  != InpMagicNumber)
         continue;                                                          // Check magic
      return true;                                                          // Position found
     }
   return false;                                                            // No position
  }

//+------------------------------------------------------------------+
//| Draws a horizontal line at the specified price level             |
//+------------------------------------------------------------------+
void DrawHLine(string name, double price, color clr, ENUM_LINE_STYLE style)
  {
   if(!InpShowLabels)
      return;                                                               // Check flag
   string obj = "WYK2_" + name;                                             // Build object name
   ObjectDelete(0, obj);                                                    // Remove existing
   ObjectCreate(0, obj, OBJ_HLINE, 0, 0, price);                            // Create hline
   ObjectSetInteger(0, obj, OBJPROP_COLOR, clr);                            // Set color
   ObjectSetInteger(0, obj, OBJPROP_STYLE, style);                          // Set line style
   ObjectSetInteger(0, obj, OBJPROP_WIDTH, 1);                              // Set width
   ChartRedraw(0);                                                          // Redraw chart
  }

//+------------------------------------------------------------------+
//| Places a text label at the specified time and price              |
//+------------------------------------------------------------------+
void DrawLabel(string name, datetime time, double price, string text, color clr)
  {
   if(!InpShowLabels)
      return;                                                               // Check flag
   string obj = "WYK2_" + name;                                             // Build object name
   ObjectDelete(0, obj);                                                    // Remove existing
   ObjectCreate(0, obj, OBJ_TEXT, 0, time, price);                          // Create text object
   ObjectSetString(0, obj, OBJPROP_TEXT, text);                             // Set text content
   ObjectSetInteger(0, obj, OBJPROP_COLOR, clr);                            // Set color
   ObjectSetInteger(0, obj, OBJPROP_FONTSIZE, 9);                           // Set font size
   ChartRedraw(0);                                                          // Redraw chart
  }

//+------------------------------------------------------------------+
//| Removes all chart objects created by this EA                     |
//+------------------------------------------------------------------+
void ClearLabels()
  {
   int total = ObjectsTotal(0);                                             // Get object count
   for(int i = total - 1; i >= 0; i--)                                      // Iterate backwards
     {
      string name = ObjectName(0, i);                                       // Get object name
      if(StringFind(name, "WYK2_") == 0)
         ObjectDelete(0, name);                                             // Delete if EA's
     }
   ChartRedraw(0);                                                          // Redraw chart
  }

All chart objects are prefixed with "WYK2_" so they can be selectively removed without affecting other objects on the chart.

Computing the Range ATR

Purpose: compute the average "ATR" over the bars that made up the detected range, to use as the basis for the "P&F" box size. Input: the number of range bars. Output: the average "ATR" value in price units, or 0 if there is insufficient data.

//+------------------------------------------------------------------+
//| Computes average ATR over the range period                       |
//+------------------------------------------------------------------+
double CalcRangeATR(int range_bars)
  {
   double atr_buf[];                                                        // ATR buffer
   ArraySetAsSeries(atr_buf, true);                                         // Set as series
   if(CopyBuffer(g_atr_handle, 0, 1, range_bars, atr_buf) < range_bars)
      return 0;                                                             // Copy ATR values
   double total = 0;                                                        // Accumulator
   for(int i = 0; i < range_bars; i++)
      total += atr_buf[i];                                                  // Sum ATR values
   return total / range_bars;                                               // Return average
  }

Using an average "ATR" over the range period rather than the current "ATR" ensures that the box size reflects the volatility that was present while the cause was being built, not the volatility at the moment of entry. A range that forms during a low-volatility period produces a smaller box size and therefore more columns than an identical-width range that forms during high volatility—correctly reflecting that the price oscillated more frequently within the available space.

Building P&F Columns from OHLC Bars

Purpose: convert the OHLC bar data of the trading range into a sequence of "P&F" columns. Input: the range support, the resistance, the bar count, the box size, the reversal factor, and an output array to fill. Output: the total column count; the "columns[]" array is filled in place.

"P&F" charts do not exist as a native data type in MQL5. "BuildPFChart()" constructs one by scanning the range bars from oldest to newest, maintaining the current column's direction and extreme price, and recording a new column whenever the price moves enough in the opposite direction to trigger a reversal.

//+------------------------------------------------------------------+
//| Builds P&F chart from bar data — returns column count            |
//+------------------------------------------------------------------+
int BuildPFChart(double range_support, double range_resistance,
                 int range_bars, double box_size,
                 int reversal_boxes, SPFColumn &columns[])
  {
   ArrayResize(columns, 0);                                                 // Clear array
   double high_buf[], low_buf[];                                            // Price buffers
   ArraySetAsSeries(high_buf, true);                                        // Set as series
   ArraySetAsSeries(low_buf,  true);                                        // Set as series
   if(CopyHigh(_Symbol, PERIOD_CURRENT, 1, range_bars, high_buf) < range_bars)
      return 0;                                                             // Copy highs
   if(CopyLow(_Symbol,  PERIOD_CURRENT, 1, range_bars, low_buf)  < range_bars)
      return 0;                                                             // Copy lows
   double reversal_dist = box_size * reversal_boxes;                        // Reversal distance
   bool   col_is_up     = (high_buf[range_bars - 1] > range_support + box_size); // First direction
   double col_extreme   = col_is_up ? high_buf[range_bars - 1] : low_buf[range_bars - 1]; // First extreme
   SPFColumn first_col;                                                     // First column
   first_col.is_up   = col_is_up;                                           // Set direction
   first_col.extreme = col_extreme;                                         // Set extreme
   first_col.base    = col_extreme;                                         // Set base
   ArrayResize(columns, 1);                                                 // Add first column
   columns[0] = first_col;                                                  // Store column
   int col_count = 1;                                                       // Column counter
   for(int i = range_bars - 2; i >= 0; i--)                                 // Scan bars oldest to newest
     {
      double bar_high = high_buf[i];                                        // Bar high
      double bar_low  = low_buf[i];                                         // Bar low
      if(col_is_up)                                                         // In up column
        {
         if(bar_high > col_extreme + box_size)                              // Extends column
           {
            col_extreme = MathFloor((bar_high - range_support) / box_size) * box_size + range_support; // Snap to grid
            columns[col_count - 1].extreme = col_extreme;                   // Update extreme
           }
         else
            if(bar_low <= col_extreme - reversal_dist)                      // Reversal
              {
               col_is_up   = false;                                         // Switch direction
               double new_base = col_extreme;                               // New base
               col_extreme = MathCeil((bar_low - range_support) / box_size) * box_size + range_support; // Snap low
               SPFColumn nc;                                                 // New column
               nc.is_up   = false;                                           // Down column
               nc.extreme = col_extreme;                                     // Set extreme
               nc.base    = new_base;                                        // Set base
               ArrayResize(columns, col_count + 1);                          // Expand array
               columns[col_count] = nc;                                      // Store column
               col_count++;                                                  // Increment count
              }
        }
      else                                                                  // In down column
        {
         if(bar_low < col_extreme - box_size)                               // Extends column
           {
            col_extreme = MathCeil((bar_low - range_support) / box_size) * box_size + range_support; // Snap to grid
            columns[col_count - 1].extreme = col_extreme;                   // Update extreme
           }
         else
            if(bar_high >= col_extreme + reversal_dist)                     // Reversal
              {
               col_is_up   = true;                                          // Switch direction
               double new_base = col_extreme;                               // New base
               col_extreme = MathFloor((bar_high - range_support) / box_size) * box_size + range_support; // Snap high
               SPFColumn nc;                                                 // New column
               nc.is_up   = true;                                            // Up column
               nc.extreme = col_extreme;                                     // Set extreme
               nc.base    = new_base;                                        // Set base
               ArrayResize(columns, col_count + 1);                          // Expand array
               columns[col_count] = nc;                                      // Store column
               col_count++;                                                  // Increment count
              }
        }
     }
   return col_count;                                                         // Return count
  }

The snapping logic—"MathFloor" multiplied by box size plus range support for highs, and "MathCeil" multiplied by box size plus range support for lows—aligns all column extremes to the nearest box boundary relative to the range support. This is important because columns must be comparable when counting at a given price level. Without grid snapping, floating-point arithmetic can produce columns whose extremes straddle a level inconsistently.

Design note: the "P&F" chart is reconstructed from bar-level OHLC data, not from intraday tick data. On very short timeframes, individual bars may contain multiple reversal-worthy moves that are invisible at the bar level. H4 and above is the recommended minimum timeframe for this approach.

Counting the Horizontal Cause

Purpose: count how many "P&F" columns pass through the count line level. Input: the completed column array, the column count, and the count line price. Output: the number of columns that include the count line within their range.

//+------------------------------------------------------------------+
//| Counts columns passing through the count line level              |
//+------------------------------------------------------------------+
int CountColumnsAtLevel(const SPFColumn &columns[], int col_count, double count_line)
  {
   int count = 0;                                                           // Initialize counter
   for(int i = 0; i < col_count; i++)                                       // Iterate columns
     {
      double col_top = MathMax(columns[i].base, columns[i].extreme);        // Column top
      double col_bot = MathMin(columns[i].base, columns[i].extreme);        // Column bottom
      if(count_line >= col_bot && count_line <= col_top)                    // Passes through level
         count++;                                                           // Increment counter
     }
   return count;                                                            // Return count
  }
A column passes through the count line when the count line price falls anywhere between the column's "base" and "extreme"—inclusive. The direction of the column is not relevant for this check. Both up columns and down columns count equally when they straddle the level. This is the horizontal width of the cause, measured at the count line.

Calculating the P&F Target

Purpose: orchestrate the full "P&F" target calculation and return a validated price target. Input: a direction flag, where true means long and false means short. All other inputs are read from "g_range." Output: the projected price target, or 0 if the calculation produces an invalid result.

//+------------------------------------------------------------------+
//| Calculates P&F price target from the confirmed range             |
//+------------------------------------------------------------------+
double CalcPFTarget(bool is_long)
  {
   double box_size = g_range.range_atr * 0.25;                              // Box size = 0.25 ATR
   double pip      = PipSize();                                             // Get pip size
   if(box_size < pip * 3)
      box_size = pip * 3;                                                   // Minimum box size
   if(box_size <= 0)
      return 0;                                                             // Invalid box size
   SPFColumn columns[];                                                     // Column array
   int col_count = BuildPFChart(g_range.support, g_range.resistance,        // Build P&F chart
                                g_range.start_bar, box_size,
                                InpPFReversalBoxes, columns);
   if(col_count < 2)
     {
      Print("WyckoffP&F: Insufficient columns (", col_count, ") — skipping P&F target."); // Log
      return 0;                                                             // Not enough columns
     }
   int h_count = CountColumnsAtLevel(columns, col_count, g_range.count_line); // Count at level
   if(h_count < 2)
     {
      Print("WyckoffP&F: Horizontal count too small (", h_count, ") — skipping."); // Log
      return 0;                                                             // Count too small
     }
   double projected_move = h_count * box_size * InpPFReversalBoxes;         // Project move
   double target = is_long                                                  // Calculate target
                   ? g_range.count_line + projected_move
                   : g_range.count_line - projected_move;
   double target_pips = MathAbs(target - g_range.count_line) / pip;        // Target in pips
   if(target_pips < InpMinTargetPips || target_pips > InpMaxTargetPips)    // Validate range
     {
      Print(StringFormat("WyckoffP&F: Target %.1f pips outside valid range [%.0f, %.0f] — skipping.",
                         target_pips, InpMinTargetPips, InpMaxTargetPips)); // Log validation
      return 0;                                                             // Invalid target
     }
   Print(StringFormat("WyckoffP&F: Box:%.5f | Cols:%d | Count:%d | Move:%.5f | Target:%.5f",
                      box_size, col_count, h_count, projected_move, target)); // Log result
   return target;                                                           // Return target
  }

The function logs every calculation: the box size, the total column count, the horizontal count at the count line, the projected move, and the final target. During testing, these Journal entries let you verify the calculation is behaving correctly on each trade before drawing conclusions from the results. When "CalcPFTarget()" returns 0, the entry functions use a 2R take profit as the fallback and log which source was used, making it straightforward to separate the two groups for analysis.

The 3-pip floor prevents the box size from collapsing on very low-volatility instruments, which would generate an unrealistically large column count. There is no upper bound on the box size in the code—the "InpMaxTargetPips" guard on the output effectively prevents oversized projections from reaching the order.

Range Detection

"DetectRange()" scans recent bars for a consolidation that meets the width, height, and prior-trend requirements. "HasPriorDowntrend()" and "HasPriorUptrend()" are helper functions called within it to verify the directional context before the range.

//+------------------------------------------------------------------+
//| Returns true if a downtrend preceded the specified bar           |
//+------------------------------------------------------------------+
bool HasPriorDowntrend(int from_bar)
  {
   double high_buf[], low_buf[];                                            // Price buffers
   ArraySetAsSeries(high_buf, true);                                        // Set as series
   ArraySetAsSeries(low_buf,  true);                                        // Set as series
   if(CopyHigh(_Symbol, PERIOD_CURRENT, from_bar, InpTrendBars, high_buf) < InpTrendBars)
      return false;                                                         // Copy highs
   if(CopyLow(_Symbol,  PERIOD_CURRENT, from_bar, InpTrendBars, low_buf)  < InpTrendBars)
      return false;                                                         // Copy lows
   double first_high = 0, second_low = DBL_MAX;                            // Init comparators
   int half = InpTrendBars / 2;                                            // Split midpoint
   for(int i = 0; i < half; i++)
      first_high = MathMax(first_high, high_buf[i + half]);                // Find early high
   for(int i = 0; i < half; i++)
      second_low = MathMin(second_low, low_buf[i]);                        // Find recent low
   return first_high > second_low + PipSize() * InpMinRangePips * 0.3;     // Confirm descent
  }

//+------------------------------------------------------------------+
//| Returns true if an uptrend preceded the specified bar            |
//+------------------------------------------------------------------+
bool HasPriorUptrend(int from_bar)
  {
   double high_buf[], low_buf[];                                            // Price buffers
   ArraySetAsSeries(high_buf, true);                                        // Set as series
   ArraySetAsSeries(low_buf,  true);                                        // Set as series
   if(CopyHigh(_Symbol, PERIOD_CURRENT, from_bar, InpTrendBars, high_buf) < InpTrendBars)
      return false;                                                         // Copy highs
   if(CopyLow(_Symbol,  PERIOD_CURRENT, from_bar, InpTrendBars, low_buf)  < InpTrendBars)
      return false;                                                         // Copy lows
   double first_low = DBL_MAX, second_high = 0;                            // Init comparators
   int half = InpTrendBars / 2;                                            // Split midpoint
   for(int i = 0; i < half; i++)
      first_low   = MathMin(first_low,   low_buf[i + half]);               // Find early low
   for(int i = 0; i < half; i++)
      second_high = MathMax(second_high, high_buf[i]);                     // Find recent high
   return second_high > first_low + PipSize() * InpMinRangePips * 0.3;    // Confirm ascent
  }
Both functions split the "InpTrendBars" lookback in half. The first half covers the earlier portion of the lookback and the second half covers the recent portion. A downtrend is confirmed when the highest point in the early half is meaningfully above the lowest point in the recent half. An uptrend is the reverse.
//+------------------------------------------------------------------+
//| Detects a valid Wyckoff trading range                            |
//+------------------------------------------------------------------+
bool DetectRange()
  {
   double high_buf[], low_buf[];                                            // Price buffers
   long   vol_buf[];                                                        // Volume buffer
   ArraySetAsSeries(high_buf, true);                                        // Set as series
   ArraySetAsSeries(low_buf,  true);                                        // Set as series
   ArraySetAsSeries(vol_buf,  true);                                        // Set as series
   int bars = InpMaxRangeBars + InpTrendBars + 5;                           // Total bars needed
   if(CopyHigh(_Symbol,       PERIOD_CURRENT, 1, bars, high_buf) < bars)
      return false;                                                         // Copy highs
   if(CopyLow(_Symbol,        PERIOD_CURRENT, 1, bars, low_buf)  < bars)
      return false;                                                         // Copy lows
   if(CopyTickVolume(_Symbol, PERIOD_CURRENT, 1, bars, vol_buf)  < bars)
      return false;                                                         // Copy volumes
   double pip   = PipSize();                                                // Get pip size
   double min_h = InpMinRangePips * pip;                                    // Min height in price
   double max_h = InpMaxRangePips * pip;                                    // Max height in price
   for(int range_bars = InpMinRangeBars; range_bars <= InpMaxRangeBars; range_bars++) // Try lengths
     {
      double rh = 0, rl = DBL_MAX;                                          // Init bounds
      for(int i = 0; i < range_bars; i++)                                   // Scan range bars
        {
         rh = MathMax(rh, high_buf[i]);                                    // Update high
         rl = MathMin(rl, low_buf[i]);                                     // Update low
        }
      double height = rh - rl;                                              // Compute height
      if(height < min_h)
         continue;                                                          // Too narrow
      if(height > max_h)
         break;                                                             // Too wide
      double tol  = height * 0.25;                                          // Tolerance band
      bool   fits = true;                                                   // Fit flag
      for(int i = 0; i < range_bars; i++)                                   // Verify bars fit
        {
         if(high_buf[i] > rh + tol || low_buf[i] < rl - tol)               // Outside tolerance
           {
            fits = false;                                                   // Mark invalid
            break;
           }
        }
      if(!fits)
         continue;                                                          // Skip this length
      bool down_before = HasPriorDowntrend(range_bars + 1);                // Check downtrend
      bool up_before   = HasPriorUptrend(range_bars + 1);                  // Check uptrend
      if(!down_before && !up_before)
         continue;                                                          // No prior trend
      double avg_vol = 0;                                                   // Volume sum
      for(int i = 0; i < range_bars; i++)
         avg_vol += (double)vol_buf[i];                                    // Accumulate
      avg_vol /= range_bars;                                               // Compute average
      double range_atr = CalcRangeATR(range_bars);                         // Compute range ATR
      g_range.support      = rl;                                            // Store support
      g_range.resistance   = rh;                                            // Store resistance
      g_range.start_bar    = range_bars;                                    // Store bar count
      g_range.avg_volume   = avg_vol;                                       // Store average vol
      //--- When both trends detected, pick the more dominant one
      if(down_before && up_before)
        {
         //--- Measure which directional move was larger immediately before range
         double high_buf[], low_buf[];
         ArraySetAsSeries(high_buf, true);
         ArraySetAsSeries(low_buf,  true);
         int trend_bars = InpTrendBars;
         CopyHigh(_Symbol, PERIOD_CURRENT, range_bars + 1, trend_bars, high_buf);
         CopyLow(_Symbol, PERIOD_CURRENT, range_bars + 1, trend_bars, low_buf);
         double trend_high = 0, trend_low = DBL_MAX;
         for(int k = 0; k < trend_bars; k++)
           {
            trend_high = MathMax(trend_high, high_buf[k]);
            trend_low  = MathMin(trend_low,  low_buf[k]);
           }
         double first_close  = iClose(_Symbol, PERIOD_CURRENT, range_bars + trend_bars);
         double last_close   = iClose(_Symbol, PERIOD_CURRENT, range_bars + 1);
         //--- If price fell into the range = downtrend before = accumulation
         //--- If price rose into the range = uptrend before = distribution
         g_range.bullish_bias = (last_close < first_close);
        }
      else
         g_range.bullish_bias = down_before;                                //Set Bias
      g_range.spring_low   = 0;                                             // Clear spring low
      g_range.sos_high     = 0;                                             // Clear SOS high
      g_range.upthrust_high= 0;                                             // Clear upthrust
      g_range.sow_low      = 0;                                             // Clear SOW low
      g_range.count_line   = 0;                                             // Clear count line
      g_range.range_atr    = range_atr;                                     // Store range ATR
      DrawHLine("SUPPORT",    g_range.support,    clrGreen, STYLE_DASH);    // Draw support
      DrawHLine("RESISTANCE", g_range.resistance, clrRed,   STYLE_DASH);    // Draw resistance
      Print(StringFormat("WyckoffEA: Range | Bias:%s | S:%.5f | R:%.5f | Bars:%d | ATR:%.5f",
                         g_range.bullish_bias ? "ACCUMULATION" : "DISTRIBUTION",
                         g_range.support, g_range.resistance, range_bars, range_atr)); // Log
      return true;                                                          // Range valid
     }
   return false;                                                            // No range found
  }

DetectRange() tests bar counts from InpMinRangeBars to InpMaxRangeBars and stops at the shortest valid range. The 25% tolerance band allows small excursions without invalidating the range. The range_atr field is populated here, so it is available for CalcPFTarget().

Wyckoff Event Detection

Each detection function examines only the last closed bar, at index 1. All functions share the same pattern: check the price condition first, then confirm with volume.

Spring detection, the accumulation entry event:

//+------------------------------------------------------------------+
//| Checks for a valid Spring on the last closed bar                 |
//+------------------------------------------------------------------+
bool CheckSpring()
  {
   double low1   = iLow(_Symbol, PERIOD_CURRENT, 1);                        // Last bar low
   double close1 = iClose(_Symbol, PERIOD_CURRENT, 1);                      // Last bar close
   double pip    = PipSize();                                               // Get pip size
   double thresh = g_range.support - InpSpringTolerance * pip;              // Spring threshold
   if(low1 < thresh && close1 > g_range.support)                            // Penetrate and recover
     {
      long vol1 = iTickVolume(_Symbol, PERIOD_CURRENT, 1);                 // Last bar volume
      if((double)vol1 < g_range.avg_volume * InpLowVolMult)                // Below threshold
        {
         g_range.spring_low  = low1;                                       // Store Spring low
         g_range.count_line  = low1 + (g_range.support - low1) * 0.5;      // Count line near LPS level
         datetime t1 = iTime(_Symbol, PERIOD_CURRENT, 1);                  // Get bar time
         DrawLabel("SPRING", t1, low1 - pip * 5, "SPRING", clrLime);       // Draw label
         Print(StringFormat("WyckoffEA: SPRING | Low:%.5f | Vol:%I64d | AvgVol:%.0f",
                            low1, vol1, g_range.avg_volume));              // Log event
         return true;                                                      // Spring confirmed
        }
     }
   return false;                                                            // Not a Spring
  }

The spring is valid when the low penetrates "InpSpringTolerance" pips below support and the close recovers back inside the range, on tick volume below the average. The below-average volume indicates that genuine supply is absent—the dip was a flush of weak hands, not institutional selling.

Sign of strength detection, the accumulation continuation event:

//+------------------------------------------------------------------+
//| Checks for a Sign of Strength on the last closed bar             |
//+------------------------------------------------------------------+
bool CheckSOS()
  {
   double close1 = iClose(_Symbol, PERIOD_CURRENT, 1);                     // Last bar close
   double high1  = iHigh(_Symbol, PERIOD_CURRENT, 1);                      // Last bar high
   if(close1 > g_range.resistance)                                         // Close above resistance
     {
      long vol1 = iTickVolume(_Symbol, PERIOD_CURRENT, 1);                 // Last bar volume
      if((double)vol1 > g_range.avg_volume * InpHighVolMult)               // High volume confirms
        {
         g_range.sos_high = high1;                                         // Store SOS high
         datetime t1 = iTime(_Symbol, PERIOD_CURRENT, 1);                  // Get bar time
         DrawLabel("SOS", t1, high1 + PipSize() * 5, "SOS", clrDodgerBlue); // Draw label
         Print(StringFormat("WyckoffEA: SOS | Close:%.5f | Vol:%I64d | AvgVol:%.0f",
                            close1, vol1, g_range.avg_volume));            // Log event
         return true;                                                      // SOS confirmed
        }
     }
   return false;                                                            // Not a SOS
  }

The sign of strength requires a close above resistance on above-average tick volume. High volume on the breakout confirms that demand is genuinely controlling the move. Without the volume confirmation, a close above resistance could be a low-conviction excursion that quickly reverses.

Upthrust detection, the distribution entry event:

//+------------------------------------------------------------------+
//| Checks for a valid Upthrust on the last closed bar               |
//+------------------------------------------------------------------+
bool CheckUpthrust()
  {
   double high1  = iHigh(_Symbol, PERIOD_CURRENT, 1);                      // Last bar high
   double close1 = iClose(_Symbol, PERIOD_CURRENT, 1);                     // Last bar close
   double pip    = PipSize();                                               // Get pip size
   double thresh = g_range.resistance + InpSpringTolerance * pip;          // Upthrust threshold
   if(high1 > thresh && close1 < g_range.resistance)                       // Penetrate and reverse
     {
      long vol1 = iTickVolume(_Symbol, PERIOD_CURRENT, 1);                 // Last bar volume
      if((double)vol1 < g_range.avg_volume * InpLowVolMult)                // Below threshold
        {
         g_range.upthrust_high = high1;                                    // Store Upthrust high
         g_range.count_line    = high1 - (high1 - g_range.resistance) * 0.5; // Count line near LPSY level
         datetime t1 = iTime(_Symbol, PERIOD_CURRENT, 1);                  // Get bar time
         DrawLabel("UT", t1, high1 + pip * 5, "UPTHRUST", clrOrangeRed);  // Draw label
         Print(StringFormat("WyckoffEA: UPTHRUST | High:%.5f | Vol:%I64d | AvgVol:%.0f",
                            high1, vol1, g_range.avg_volume));             // Log event
         return true;                                                      // Upthrust confirmed
        }
     }
   return false;                                                            // Not an Upthrust
  }
Sign of weakness detection, the distribution continuation event:
//+------------------------------------------------------------------+
//| Checks for a Sign of Weakness on the last closed bar             |
//+------------------------------------------------------------------+
bool CheckSOW()
  {
   double close1 = iClose(_Symbol, PERIOD_CURRENT, 1);                     // Last bar close
   double low1   = iLow(_Symbol, PERIOD_CURRENT, 1);                       // Last bar low
   if(close1 < g_range.support)                                            // Close below support
     {
      long vol1 = iTickVolume(_Symbol, PERIOD_CURRENT, 1);                 // Last bar volume
      if((double)vol1 > g_range.avg_volume * InpHighVolMult)               // High volume confirms
        {
         g_range.sow_low = low1;                                           // Store SOW low
         datetime t1 = iTime(_Symbol, PERIOD_CURRENT, 1);                  // Get bar time
         DrawLabel("SOW", t1, low1 - PipSize() * 5, "SOW", clrCrimson);   // Draw label
         Print(StringFormat("WyckoffEA: SOW | Close:%.5f | Vol:%I64d | AvgVol:%.0f",
                            close1, vol1, g_range.avg_volume));            // Log event
         return true;                                                      // SOW confirmed
        }
     }
   return false;                                                            // Not a SOW
  }
The upthrust and the sign of weakness are structural mirrors of the spring and the sign of strength. The upthrust flushes weak shorts above resistance on low volume; the sign of weakness confirms that supply is in control with a close below support on high volume.

Entry Functions

"CheckLPSEntry()" and "CheckLPSYEntry()" are called on each new bar after the sign of strength or the sign of weakness is confirmed. They wait up to "InpLPSBars" bars for the pullback to meet conditions, then set the count line, calculate the "P&F" target, and open the position.

Long entry at the last point of support:

//+------------------------------------------------------------------+
//| Waits for LPS pullback, calculates P&F target, opens long        |
//+------------------------------------------------------------------+
void CheckLPSEntry()
  {
   g_lps_count++;                                                           // Increment wait counter
   if(g_lps_count > InpLPSBars)                                            // Wait window expired
     {
      Print("WyckoffEA: LPS window expired — resetting.");                 // Log expiry
      g_state = STATE_IDLE;                                                // Reset state
      ClearLabels();                                                       // Clear chart
      return;
     }
   double close1 = iClose(_Symbol, PERIOD_CURRENT, 1);                     // Last bar close
   long   vol1   = iTickVolume(_Symbol, PERIOD_CURRENT, 1);                // Last bar volume
   bool pulled_back = (close1 < g_range.sos_high && close1 > g_range.support); // Pullback check
   bool low_vol     = ((double)vol1 < g_range.avg_volume * InpHighVolMult);    // Volume check
   Print(StringFormat("WyckoffEA: LPS check %d/%d | Close:%.5f | PulledBack:%s | LowVol:%s",
                      g_lps_count, InpLPSBars, close1,
                      pulled_back ? "YES" : "NO", low_vol ? "YES" : "NO")); // Log check
   if(pulled_back && low_vol)                                              // LPS conditions met
     {
      g_range.count_line = close1;                                         // Update count line to LPS close
      double pf_target   = CalcPFTarget(true);                             // Calculate P&F target
      double atr_buf[];                                                    // ATR buffer
      ArraySetAsSeries(atr_buf, true);                                     // Set as series
      if(CopyBuffer(g_atr_handle, 0, 1, 1, atr_buf) < 1)
         return;                                                           // Copy ATR
      double atr    = atr_buf[0];                                          // ATR value
      double ask    = SymbolInfoDouble(_Symbol, SYMBOL_ASK);               // Current ask
      double sl     = ask - atr * InpATRMult;                              // Stop loss price
      double sl_pip = (ask - sl) / PipSize();                              // Stop in pips
      double tp     = (pf_target > 0) ? pf_target : ask + sl_pip * 2.0 * PipSize(); // P&F target or 2R fallback
      double lots   = CalcLots(sl_pip);                                    // Calculate lot size
      if(lots <= 0)
         return;                                                           // Invalid lot size
      long   stop_lv  = SymbolInfoInteger(_Symbol, SYMBOL_TRADE_STOPS_LEVEL); // Broker stop level
      double min_dist = stop_lv * _Point;                                  // Minimum distance
      if(ask - sl < min_dist)
         sl = ask - min_dist - _Point;                                     // Adjust SL if needed
      if(tp < ask + min_dist)
         tp = ask + min_dist + _Point;                                     // Adjust TP if needed
      if(g_trade.Buy(lots, _Symbol, ask, sl, tp, "Wyckoff LPS Long"))     // Open long
        {
         string tp_label = (pf_target > 0) ? "P&F" : "2R";               // Target label
         DrawHLine("TARGET", tp, clrGold, STYLE_DOT);                     // Draw target line
         datetime t1 = iTime(_Symbol, PERIOD_CURRENT, 1);                 // Get bar time
         DrawLabel("LPS", t1, iLow(_Symbol, PERIOD_CURRENT, 1) - PipSize() * 3, "LPS", clrGold); // Draw LPS label
         Print(StringFormat("WyckoffEA: LONG | Lots:%.2f | SL:%.5f | TP:%.5f (%s)",
                            lots, sl, tp, tp_label));                     // Log trade
         g_state = STATE_IN_TRADE;                                         // Update state
        }
     }
  }

The count line is set to the last point of support close at the exact moment the pullback is confirmed, not earlier. This is important: the count line must reflect the actual level from which the markup begins, and that level is not known until the pullback bar closes. Setting "g_range.count_line" to "close1" immediately before calling "CalcPFTarget()" ensures the measurement is always taken at the correct price.

The take profit selection is a single conditional: if the "P&F" target exceeds zero, use the "P&F" projection; otherwise use a 2R fixed ratio. The "(P&F)" or "(2R)" label in the log makes it easy to separate the two groups during analysis.

Short entry at the last point of supply:

//+------------------------------------------------------------------+
//| Waits for LPSY rally, calculates P&F target, opens short         |
//+------------------------------------------------------------------+
void CheckLPSYEntry()
  {
   g_lpsy_count++;                                                          // Increment wait counter
   if(g_lpsy_count > InpLPSBars)                                           // Wait window expired
     {
      Print("WyckoffEA: LPSY window expired — resetting.");                // Log expiry
      g_state = STATE_IDLE;                                                // Reset state
      ClearLabels();                                                       // Clear chart
      return;
     }
   double close1 = iClose(_Symbol, PERIOD_CURRENT, 1);                     // Last bar close
   long   vol1   = iTickVolume(_Symbol, PERIOD_CURRENT, 1);                // Last bar volume
   bool rallied = (close1 > g_range.sow_low && close1 < g_range.resistance); // Rally check
   bool low_vol = ((double)vol1 < g_range.avg_volume * InpHighVolMult);       // Volume check
   Print(StringFormat("WyckoffEA: LPSY check %d/%d | Close:%.5f | Rallied:%s | LowVol:%s",
                      g_lpsy_count, InpLPSBars, close1,
                      rallied ? "YES" : "NO", low_vol ? "YES" : "NO"));   // Log check
   if(rallied && low_vol)                                                  // LPSY conditions met
     {
      g_range.count_line = close1;                                         // Update count line to LPSY close
      double pf_target   = CalcPFTarget(false);                            // Calculate P&F target
      double atr_buf[];                                                    // ATR buffer
      ArraySetAsSeries(atr_buf, true);                                     // Set as series
      if(CopyBuffer(g_atr_handle, 0, 1, 1, atr_buf) < 1)
         return;                                                           // Copy ATR
      double atr    = atr_buf[0];                                          // ATR value
      double bid    = SymbolInfoDouble(_Symbol, SYMBOL_BID);               // Current bid
      double sl     = bid + atr * InpATRMult;                              // Stop loss price
      double sl_pip = (sl - bid) / PipSize();                              // Stop in pips
      double tp     = (pf_target > 0) ? pf_target : bid - sl_pip * 2.0 * PipSize(); // P&F target or 2R fallback
      double lots   = CalcLots(sl_pip);                                    // Calculate lot size
      if(lots <= 0)
         return;                                                           // Invalid lot size
      long   stop_lv  = SymbolInfoInteger(_Symbol, SYMBOL_TRADE_STOPS_LEVEL); // Broker stop level
      double min_dist = stop_lv * _Point;                                  // Minimum distance
      if(sl - bid < min_dist)
         sl = bid + min_dist + _Point;                                     // Adjust SL if needed
      if(bid - tp < min_dist)
         tp = bid - min_dist - _Point;                                     // Adjust TP if needed
      if(g_trade.Sell(lots, _Symbol, bid, sl, tp, "Wyckoff LPSY Short"))  // Open short
        {
         string tp_label = (pf_target > 0) ? "P&F" : "2R";               // Target label
         DrawHLine("TARGET", tp, clrGold, STYLE_DOT);                     // Draw target line
         datetime t1 = iTime(_Symbol, PERIOD_CURRENT, 1);                 // Get bar time
         DrawLabel("LPSY", t1, iHigh(_Symbol, PERIOD_CURRENT, 1) + PipSize() * 3, "LPSY", clrGold); // Draw LPSY label
         Print(StringFormat("WyckoffEA: SHORT | Lots:%.2f | SL:%.5f | TP:%.5f (%s)",
                            lots, sl, tp, tp_label));                     // Log trade
         g_state = STATE_IN_TRADE;                                         // Update state
        }
     }
  }

The last point of supply entry mirrors the last point of support entry in the short direction. "CalcPFTarget(false)" is called instead of "CalcPFTarget(true)," and the bid price replaces the ask. The stop is placed above the entry at the "ATR" value multiplied by "InpATRMult," added to the bid. All other logic is identical.

Event Handlers

"OnInit()" creates the "ATR" indicator handle, configures the trade object, and initializes all state variables. "OnDeinit()" releases the handle and removes chart objects. "OnTick()" runs the state machine once per new bar.

//+------------------------------------------------------------------+
//| Expert initialization function                                   |
//+------------------------------------------------------------------+
int OnInit()
  {
   g_atr_handle = iATR(_Symbol, PERIOD_CURRENT, InpATRPeriod);             // Create ATR handle
   if(g_atr_handle == INVALID_HANDLE)                                      // Check handle
     {
      Print("WyckoffCauseEffectEA: ATR handle creation failed.");          // Log error
      return INIT_FAILED;                                                  // Return failure
     }
   g_trade.SetExpertMagicNumber(InpMagicNumber);                           // Set magic number
   g_trade.SetDeviationInPoints(InpSlippage);                              // Set slippage
   g_state       = STATE_IDLE;                                             // Initialize state
   g_lps_count   = 0;                                                      // Initialize LPS counter
   g_lpsy_count  = 0;                                                      // Initialize LPSY counter
   g_range_watch = 0;                                                      // Initialize watch counter
   g_last_bar    = 0;                                                      // Initialize bar time
   Print("WyckoffCauseEffectEA initialized | Symbol:", _Symbol,
         " | TF:", EnumToString(Period()),
         " | Magic:", InpMagicNumber);                                     // Log initialization
   return INIT_SUCCEEDED;                                                  // Return success
  }

//+------------------------------------------------------------------+
//| Expert deinitialization function                                 |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
   IndicatorRelease(g_atr_handle);                                          // Release ATR handle
   ClearLabels();                                                           // Remove chart objects
  }
A single "ATR" handle serves both purposes: the stop distance calculation and the range "ATR" averaging. No additional indicator handles are required.
//+------------------------------------------------------------------+
//| Expert tick function                                             |
//+------------------------------------------------------------------+
void OnTick()
  {
   datetime current_bar = iTime(_Symbol, PERIOD_CURRENT, 0);               // Current bar open time
   if(current_bar == g_last_bar)
      return;                                                              // Skip if same bar
   g_last_bar = current_bar;                                               // Update last bar time
   if(g_state == STATE_IN_TRADE)                                           // In trade state
     {
      if(!PositionOpen())                                                  // Position has closed
        {
         Print("WyckoffCauseEffectEA: Trade closed — returning to IDLE."); // Log closure
         g_state       = STATE_IDLE;                                       // Reset state
         g_lps_count   = 0;                                                // Reset LPS counter
         g_lpsy_count  = 0;                                                // Reset LPSY counter
         g_range_watch = 0;                                                // Reset watch counter
         ClearLabels();                                                    // Clear chart
        }
      return;                                                              // Exit tick
     }
   switch(g_state)                                                         // State machine switch
     {
      case STATE_IDLE:                                                     // Idle state
         if(DetectRange())                                                 // Range found
           {
            g_state       = STATE_RANGE_FORMING;                          // Advance state
            g_range_watch = 0;                                             // Reset watch counter
           }
         break;
      case STATE_RANGE_FORMING:                                            // Range locked
         g_range_watch++;                                                  // Increment watch counter
         if(g_range_watch > InpRangeWatchBars)                            // Range stale
           {
            Print("WyckoffCauseEffectEA: Range watch expired — resetting."); // Log expiry
            g_state = STATE_IDLE;                                          // Reset state
            ClearLabels();                                                 // Clear chart
            break;
           }
         if(g_range.bullish_bias)                                         // Accumulation bias
           {
            if(CheckSpring())
               g_state = STATE_SPRING_DETECTED;                           // Spring found
           }
         else                                                              // Distribution bias
           {
            if(CheckUpthrust())
               g_state = STATE_UPTHRUST_DETECTED;                         // Upthrust found
           }
         break;
      case STATE_SPRING_DETECTED:                                          // Spring detected
         if(CheckSOS())                                                    // SOS found
           {
            g_state     = STATE_SOS_CONFIRMED;                            // Advance state
            g_lps_count = 0;                                               // Reset LPS counter
           }
         else
            if(iClose(_Symbol, PERIOD_CURRENT, 1) < g_range.spring_low)  // Spring invalidated
              {
               Print("WyckoffCauseEffectEA: Spring invalidated — resetting."); // Log failure
               g_state = STATE_IDLE;                                       // Reset state
               ClearLabels();                                              // Clear chart
              }
         break;
      case STATE_SOS_CONFIRMED:                                            // SOS confirmed
         CheckLPSEntry();                                                  // Check LPS entry
         break;
      case STATE_UPTHRUST_DETECTED:                                        // Upthrust detected
         if(CheckSOW())                                                    // SOW found
           {
            g_state      = STATE_SOW_CONFIRMED;                           // Advance state
            g_lpsy_count = 0;                                              // Reset LPSY counter
           }
         else
            if(iClose(_Symbol, PERIOD_CURRENT, 1) > g_range.upthrust_high) // Upthrust invalidated
              {
               Print("WyckoffCauseEffectEA: Upthrust invalidated — resetting."); // Log failure
               g_state = STATE_IDLE;                                       // Reset state
               ClearLabels();                                              // Clear chart
              }
         break;
      case STATE_SOW_CONFIRMED:                                            // SOW confirmed
         CheckLPSYEntry();                                                 // Check LPSY entry
         break;
     }
  }
//+------------------------------------------------------------------+

"OnTick()" runs only once per new bar—the "g_last_bar" gate at the top filters out intra-bar ticks. This is correct for signal detection, which should evaluate completed bars only, but means that position management such as stop adjustments, trailing, or break-even would need to move outside this gate if added in a future version. The state machine processes exactly one state per bar and exits immediately after. Invalidation conditions are checked before advancing, so the machine cannot skip a state.

Key Implementation Choices

The box size is set to 0.25 times the range "ATR," which is volatility-adjusted and instrument-agnostic. A minimum box size of 3 pips prevents excessive column counts on low-volatility symbols. The reversal factor is fixed at 1 box, which captures the maximum internal oscillation within the range. The count line is taken from the last point of support or the last point of supply close, giving an objective and reproducible substitute for visual judgment. The fallback take profit is 2R, applied only when the "P&F" structure is too narrow for a valid projection. The stop distance is 1.5 times the "ATR," placed beyond one average range move from entry. Risk per trade is fixed at 1 percent of balance, sized through the tick value. The range tolerance is 25 percent of the range height, allowing minor excursions without disqualifying the range. The volume threshold is 1.2 times the average, applied to both the high-volume and the low-volume event checks.

Backtesting

To test the EA, open the MetaTrader 5 Strategy Tester and configure it as follows. Symbol = EURUSD. Timeframe = H4. Modeling = every tick based on real ticks. Initial deposit = $10,000. Test period = 2025.04.01 to 2026.04.01. Use the same range and volume input values as Part 1. For the P&F settings, begin with "InpPFReversalBoxes" = 1, "InpMinTargetPips" = 30, and "InpMaxTargetPips" = 500.

What to Expect

The journal will log each P&F calculation in detail: the box size derived from ATR, the total column count, the horizontal count at the count line, the projected move, and the resulting target. This output allows you to verify that the calculation is behaving correctly before drawing any conclusions from the results.

Trades where the P&F target was valid will show "TP:%.5f (P&F)" in the log. Trades where the structure was too narrow will show "TP:%.5f (2R)"—indicating the fallback was used. Track these separately to compare the performance of structurally targeted trades against fallback trades.

If the P&F target consistently falls short of actual price movement, the box size may be too large—increase the ATR multiplier for box size from 0.25 toward 0.35. If the target is consistently overshooting, decrease it toward 0.20.

Test Results

cause and effect demonstration

Figure 2. Demonstration test.

graph

Figure 3. Equity and balance curve.

results

Figure 4. Test results.

entries

Figure 5. Test entries.


Known Limitations

The P&F box size is derived from ATR, not from an absolute price level. This is instrument-agnostic but means the box size changes as volatility changes. A range that forms during a low-volatility period will have a smaller box size and therefore more columns than an identical-width range forming during a high-volatility period. The resulting target will differ. This is mathematically correct—the P&F count is sensitive to the scale at which it is measured—but it means targets are not directly comparable across different market conditions.

The count line is set at the LPS or LPSY close, not at a manually identified last point of support level. In Wyckoff's original method, the count line is drawn at a specific structural level that requires visual judgment. The EA approximates this by using the pullback close as the count line. This produces a conservative and objective measurement but may differ from what an experienced Wyckoff analyst would select.

The EA uses a 1-box reversal throughout. Wyckoff practitioners sometimes use 3-box reversals for longer-term counts. The choice of reversal factor has a significant effect on the column count and therefore on the projected target. A future extension could offer both counts and use the average as the target zone.

The P&F construction scans bar-level OHLC data, not intraday tick data. On very short timeframes, individual bars may contain multiple reversal-worthy moves that are invisible at the bar level. H4 and above are the recommended minimum timeframes for this approach. On H1, results should be verified carefully against visual P&F analysis.

Conclusion

Wyckoff's law of cause and effect is one of the most elegant ideas in technical analysis: the size of a trend is proportional to the size of the consolidation that preceded it. The point and figure count is the tool that converts that idea into a number.

Part 1 of this series built the detection and entry architecture. This article builds the measurement layer that makes the exit structural rather than arbitrary. When the EA enters a trade at the last point of support, it now knows how wide the accumulation range was, how many P&F columns it generated, and how far the subsequent markup phase is projected to travel. The take profit is no longer a multiple of the stop distance—it is a measurement of the cause.

Together, Part 1 (entry) and Part 2 (exit) form a complete Wyckoff-based system. The EA enters at the lowest-risk point before markup and exits at the projected effect of the accumulated cause. Both decisions are derived from price structure rather than external indicators.

All code was compiled and tested in MetaTrader 5. The complete "WyckoffCauseEffectEA.mq5" source file is attached to this article. Copy to "MQL5\Experts\WyckoffCauseEffectEA.mq5" and compile in MetaEditor with no additional dependencies. Recommended for EURUSD on H4. Always test on a demo account before live deployment.

Attached files |
MQL5 Wizard Techniques you should know (Part 99): Using a KD-Tree and an Echo State Network in a Custom Money Management Class MQL5 Wizard Techniques you should know (Part 99): Using a KD-Tree and an Echo State Network in a Custom Money Management Class
This article lays out 'CMoneyKDTreeESN' custom money management class usable with the MQL5 Wizard, that combines the KD-Tree algorithm and the Echo State Network. We use the KD-Tree on log returns and ATR to give us a risk score, while the ESN tracks recent flow to give us a bounded lot size multiplier. Our class is usable in a variety of Wizard assembled Expert Advisors as shown here with the Envelopes and RSI signals, with a broad objective of modulating exposure in high-volatility and tail-risk environments.
Creating an HTML Dashboard for Strategy Tester and Prop Firm Challenge Analysis in MQL5 Creating an HTML Dashboard for Strategy Tester and Prop Firm Challenge Analysis in MQL5
This article demonstrates how to build a reusable prop‑firm evaluation module for MQL5 Expert Advisors and export results to an HTML dashboard. The module monitors balance and equity during backtests, simulates single or rolling challenges, checks profit target, daily and overall drawdown, and minimum trading days, then outputs both a terminal summary and a browser‑readable report.
Risk Manager for Trading Robots (Part I): Risk Control Include File for Expert Advisors Risk Manager for Trading Robots (Part I): Risk Control Include File for Expert Advisors
Trading is characterized by high demands on risk management discipline. The article presents an analysis of the main reasons for traders' failures and proposes a technical solution in the form of the CEnhancedRiskManager class for the MQL5 platform. It includes practical testing on an aggressive grid EA.
Lazy-Loading Indicator Handles in MQL5: A Resource Manager Pattern for Multi-Timeframe EAs Lazy-Loading Indicator Handles in MQL5: A Resource Manager Pattern for Multi-Timeframe EAs
Multi‑timeframe EAs that initialize every indicator handle in OnInit() pay a fixed startup cost even when most handles are never used. CIndicatorCache applies lazy loading with composite‑key lookup, reference‑counted Acquire/Release, and a deterministic FlushAll() for cleanup. Handles are created on first request and reused across ticks, reducing startup latency, avoiding repeated heap allocation, and preventing terminal resource leaks through centralized ownership.