English
preview
Автоматизация классических рыночных методов в MQL5 (Часть 2): Закон причины и следствия Вайкоффа — ценовые цели по графику "крестики-нолики"

Автоматизация классических рыночных методов в MQL5 (Часть 2): Закон причины и следствия Вайкоффа — ценовые цели по графику "крестики-нолики"

MetaTrader 5Примеры |
35 1
Tola Moses Hector
Tola Moses Hector

Введение

В Части 1 мы создали советник, который распознаёт накопление и распределение по Вайкоффу с помощью конечного автомата. Для длинных позиций он подтверждает последовательность «спринг → SOS», а для коротких — «аптраст → SOW», после чего открывает позицию в области последней точки поддержки или последней точки предложения.

Тот советник отвечал на вопрос, где входить в позицию. Эта статья отвечает на связанный с ним вопрос, который Вайкофф считал не менее важным: где из неё выходить. Ответ даёт его второй закон — закон причины и следствия — и горизонтальный подсчёт по графику «крестики-нолики», позволяющий измерить эту связь. Советник, описанный в этой статье, полностью автономен: ниже приведены все необходимые для его работы функции, а код из Части 1 не требуется.

Ричард Вайкофф учил, что рынок проходит две основные фазы: накопление, в ходе которого крупные операторы незаметно поглощают предложение по низким ценам, и распределение, когда они сбрасывают эти позиции по высоким ценам. Переход от накопления к росту или от распределения к снижению не случаен. Он представляет собой пропорциональное следствие измеримой причины. Вайкофф называл это своим вторым законом и предложил трейдерам точный инструмент измерения — подсчёт по графику «крестики-нолики».

Далее мы реализуем полноценный советник на MQL5, автоматизирующий торговый цикл Вайкоффа. Он распознаёт структуры с помощью конечного автомата, подтверждает ключевые события, открывает позицию в структурно корректной точке входа и рассчитывает тейк-профит по модели диапазона на основе графика «крестики-нолики».

Внешние библиотеки не требуются: достаточно стандартного include-файла "Trade\Trade.mqh".

В статье рассмотрены следующие темы:

  1. Накопление и распределение по Вайкоффу
  2. Закон причины и следствия: теория и формула
  3. Архитектура решения
  4. Реализация на MQL5
  5. Ключевые решения при реализации
  6. Тестирование
  7. Известные ограничения
  8. Заключение


Накопление и распределение по Вайкоффу

Вайкофф выделил две структурные модели, предшествующие крупным ценовым движениям: накопление перед фазой роста и распределение перед фазой снижения. Обе модели развиваются в узнаваемой последовательности событий. Чтобы понять логику решений советника, нужно понимать эту последовательность.

Последовательность накопления

Накопление формируется после нисходящего тренда. Крупные участники рынка — то, что Вайкофф называл Composite Man, совокупным крупным оператором, — начинают поглощать предложение частных продавцов. Структура проходит через пять фаз, однако советник сосредоточен на конкретных событиях, подтверждающих её формирование.

Первое ключевое событие — спринг. Цена ненадолго пробивает поддержку, опускаясь ниже минимума диапазона, а затем на том же баре возвращается внутрь диапазона. Это движение выбивает слабых держателей длинных позиций и проверяет, осталось ли на рынке реальное предложение. Спринг при относительно низком тиковом объёме указывает на исчерпание давления продавцов.

За спрингом следует признак силы. Цена закрывается выше сопротивления на высоком тиковом объёме, показывая, что инициатива перешла к покупателям и происходит выход из диапазона вверх.

Последняя точка поддержки служит сигналом для входа. После признака силы цена на низком объёме откатывается к уровню сопротивления, который теперь стал поддержкой. Этот откат является последним ретестом перед началом фазы роста. Советник открывает длинную позицию в последней точке поддержки.

Зеркало накопления: распределение

Распределение является зеркальным отражением накопления. Оно формируется после восходящего тренда, когда крупные участники рынка передают свои позиции частным покупателям.

Аптраст — ключевое событие, соответствующее спрингу. Цена ненадолго пробивает сопротивление, а затем на том же баре возвращается внутрь диапазона при относительно низком тиковом объёме. После аптраста появляется признак слабости: цена закрывается ниже поддержки на высоком тиковом объёме.

Последняя точка предложения служит сигналом для входа. После признака слабости цена на низком объёме поднимается к уровню поддержки, который теперь выступает сопротивлением. Советник открывает короткую позицию в последней точке предложения.

Почему важна последовательность распознавания

Каждое из этих событий имеет значение только в соответствующем контексте. Закрытие ниже поддержки не является признаком слабости, если до этого не был подтверждён аптраст. Откат после пробоя не является последней точкой поддержки, если ему последовательно не предшествовали спринг и признак силы. Изолированная проверка любого отдельного события без учёта предшествующей структуры даст множество ложных сигналов. Конечный автомат обеспечивает правильную последовательность: каждое событие должно следовать за предыдущим. Если последовательность нарушается — например, цена закрывается ниже минимума спринга до подтверждения признака силы, — автомат возвращается в состояние ожидания и начинает поиск заново.

Закон причины и следствия: теория и формула

cause and effect buy

Рисунок 1. График со спрингом, SOS, LPS, точкой входа и целью по закону причины и следствия.

Формула ценовой цели по графику «крестики-нолики» имеет два варианта: для накопления и для распределения.

Для цели длинной позиции из диапазона накопления:

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

Для цели короткой позиции из диапазона распределения:

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

Каждый элемент формулы имеет точное определение.

Линия подсчёта — это ценовой уровень, на котором выполняется горизонтальный подсчёт. Для накопления Вайкофф предписывал проводить её на уровне последней точки поддержки — то есть последнего минимума перед признаком силы. На этом уровне проявился наиболее сильный институциональный спрос, поэтому он является самым консервативным уровнем для проецирования цели. Для распределения линия подсчёта проводится на уровне последней точки предложения — то есть последнего максимума перед признаком слабости.

Количество столбцов — это число столбцов графика «крестики-нолики» внутри торгового диапазона на уровне линии подсчёта. Столбец учитывается, если график фиксирует разворот на этом ценовом уровне или проходит через него. Чем шире диапазон накопления, тем больше в нём столбцов и тем выше расчётная цель.

Размер клетки — это шаг цены, представляемый каждым крестиком X или ноликом O. При слишком малом размере незначительные колебания создают шум, а при слишком большом скрывается структура. В этой статье размер клетки рассчитывается по ATR диапазона (примерно 0,25 ATR), что обеспечивает адаптацию к волатильности различных инструментов.

Коэффициент разворота — это количество клеток, необходимое для формирования нового столбца. Разворот на одну клетку наиболее подробно отражает движение: он даёт наибольшее число столбцов и, следовательно, наиболее оптимистичную цель. Для внутридневной и свинг-торговли валютными парами обычно используют разворот на одну клетку. Разворот на три клетки чаще применяется в долгосрочном анализе и даёт более консервативные цели, поскольку при том же движении цены формируется меньше столбцов.

В статье используется разворот на одну клетку. Он наиболее полно отражает колебания внутри торгового диапазона и тем самым обеспечивает наиболее точный подсчёт столбцов.

Рассмотрим пример с теми же параметрами EURUSD, что использовались в Части 1:

Советник определил диапазон от 1,0680 (поддержка) до 1,0820 (сопротивление). ATR в период формирования диапазона составляет 22 пипса. Размер клетки: 0,25 × 22 = 5,5 пипса; для удобства расчёта округлим его до 5 пипсов. Линия подсчёта, проведённая через последнюю точку поддержки, находится на уровне 1,0700. Представление диапазона в виде графика «крестики-нолики» даёт 18 столбцов на уровне 1,0700. Коэффициент разворота равен 1.

Цель длинной позиции = (18 × 5 пипсов × 1) + 1,0700 = 1,0700 + 90 пипсов = 1,0790

Согласно расчёту, фаза роста должна достичь уровня 1,0790. Если фактический рост выводит цену выше 1,0820 — верхней границы диапазона, — цель, вероятно, была консервативной, что полностью соответствует подходу Вайкоффа. Он рекомендовал сначала использовать самый консервативный подсчёт.

Архитектура решения

Перед написанием кода полезно понять, как связаны его части. Советник работает как конвейер: каждый этап передаёт результат следующему.

Бары OHLC и тиковый объём
        ↓
  DetectRange()
  Определяет поддержку, сопротивление, контекст накопления/распределения, средний объём и ATR диапазона
        ↓
  CheckSpring() или CheckUpthrust()
  Подтверждает структурное событие входа на низком объёме
        ↓
  CheckSOS() или CheckSOW()
  Подтверждает направленный выход из диапазона на высоком объёме
        ↓
  CheckLPSEntry() или CheckLPSYEntry()
  Ожидает отката и устанавливает линию подсчёта
        ↓
  CalcPFTarget()
  Последовательно вызывает CalcRangeATR, BuildPFChart и CountColumnsAtLevel
  Возвращает расчётную цену или 0 для использования резервной цели
        ↓
  CTrade.Buy() или CTrade.Sell()
  Открывает позицию со структурным тейк-профитом или с резервной целью 2R

Конечный автомат определяет, какой этап активен на текущем баре. Переход к следующему состоянию происходит только после подтверждения условий текущего этапа; при выполнении условия отмены автомат возвращается в состояние ожидания.

Состояния конечного автомата

Конечный автомат проходит через семь состояний. "STATE_IDLE" означает, что активной структуры нет и советник ищет диапазон. "STATE_RANGE_FORMING" означает, что валидный диапазон определён и советник ожидает спринг или аптраст. "STATE_SPRING_DETECTED" означает, что спринг подтверждён и советник ожидает признак силы. "STATE_SOS_CONFIRMEDозначает, что признак силы подтверждён и советник ожидает откат к последней точке поддержки. "STATE_UPTHRUST_DETECTED" означает, что аптраст подтверждён и советник ожидает признак слабости. "STATE_SOW_CONFIRMED" означает, что признак слабости подтверждён и советник ожидает подъём к последней точке предложения. "STATE_IN_TRADE" означает, что позиция открыта и советник отслеживает её закрытие.

Карта функций

"DetectRange()" определяет корректный торговый диапазон Вайкоффа по барам OHLC. "HasPriorDowntrend()" подтверждает, что диапазону предшествовал нисходящий тренд, создающий контекст накопления. "HasPriorUptrend()" подтверждает, что диапазону предшествовал восходящий тренд, создающий контекст распределения. "CheckSpring()" распознаёт спринг на последнем закрытом баре. "CheckSOS()" распознаёт признак силы на последнем закрытом баре. "CheckUpthrust()" распознаёт аптраст на последнем закрытом баре. "CheckSOW()" распознаёт признак слабости на последнем закрытом баре. "CheckLPSEntry()" ожидает откат к последней точке поддержки и открывает длинную позицию. "CheckLPSYEntry()" ожидает подъём к последней точке предложения и открывает короткую позицию. "CalcRangeATR()" рассчитывает среднее значение "ATR" по барам диапазона. "BuildPFChart()" преобразует данные баров OHLC в последовательность столбцов "P&F" . "CountColumnsAtLevel()" подсчитывает количество столбцов, проходящих через линию подсчёта. "CalcPFTarget()" координирует полный расчёт цели по "P&F" . "CalcLots()" рассчитывает размер позиции с учётом денежного риска и расстояния до стоп-лосса. "PipSize()" возвращает размер пункта для текущего символа.

Реализация на MQL5

В каждом следующем разделе рассматривается отдельная логическая часть советника: назначение кода, сам фрагмент и пояснение ключевых решений.

Заголовок, подключаемые файлы и перечисления

Советнику требуется только стандартная торговая библиотека. Перечисление конечного автомата задаёт семь возможных состояний конвейера распознавания структуры Вайкоффа.

//+------------------------------------------------------------------+
//|                                        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
  };

Входные параметры

Входные параметры разделены на четыре группы. Группа "Range Detection" («Определение диапазона») определяет, как советник выявляет корректную консолидацию. Группа "Volume Settings" («Настройки объёма») задаёт пороги тикового объёма, позволяющие отличать значимые события от шума. Группа "P&F Target Settings" («Настройки целей P&F») управляет расчётом тейк-профита. Группа "Entry and Risk" («Вход и риск») управляет размещением стоп-лосса и расчётом размера позиции.

//+------------------------------------------------------------------+
//| 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" задаёт количество клеток, которое цена должна пройти в противоположном направлении для регистрации нового столбца. При значении 1 движение на одну клетку в обратную сторону начинает новый столбец, обеспечивая наиболее полное представление колебаний внутри диапазона. "InpMinTargetPips" и "InpMaxTargetPips" защищают от бессмысленных проекций: если рассчитанная цель выходит за заданные пределы, советник использует резервный тейк-профит 2R и записывает причину в журнал.

Структуры данных

Рабочее состояние советника хранится в двух структурах. "SPFColumn" представляет один столбец графика «крестики-нолики». "SWyckoffRange" содержит все данные о текущем обнаруженном торговом диапазоне, включая два поля, добавленные для расчёта "P&F" : "range_atr" и "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
  };
Трёх полей достаточно, чтобы определить, пересекает ли столбец заданный ценовой уровень. Для проверки линии подсчёта нужны только "base" и "extreme." Флаг "is_up" определяет, какой ценовой экстремум отслеживается при построении.
//+------------------------------------------------------------------+
//| 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
  };

Поле "count_line" инициализируется нулём в "DetectRange()" и получает фактическую цену закрытия последней точки поддержки или предложения только после подтверждения условий входа. Благодаря этому подсчёт всегда выполняется на реальном уровне отката, а не на заранее оценённом уровне. Поле "range_atr" заполняется вместе с другими параметрами диапазона и используется функцией "CalcPFTarget()" как основа для размера клетки.

Глобальные переменные и вспомогательные функции

//+------------------------------------------------------------------+
//| 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()" возвращает корректный размер пункта для символов с 4 или 2 знаками, а также с 5 или 3 знаками. "CalcLots()" рассчитывает размер позиции по стоимости и размеру тика, указанным в спецификации символа, поэтому корректно работает не только с обычными валютными парами, но и с металлами и CFD.
//+------------------------------------------------------------------+
//| 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
  }

Вместо фиксированной стоимости пункта денежный риск рассчитывается как размер пункта, делённый на размер тика и умноженный на стоимость тика и объём позиции. Такой подход корректно работает для пар с JPY, золота, индексов и других инструментов, где упрощённое умножение пунктов на лоты даёт неверный результат.

За визуальное отображение отвечают три вспомогательные функции графика:

//+------------------------------------------------------------------+
//| 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
  }

Имена всех объектов советника на графике начинаются с префикса "WYK2_", поэтому их можно удалить выборочно, не затрагивая другие объекты.

Расчёт ATR диапазона

Назначение: рассчитать среднее значение ATR по барам обнаруженного диапазона и использовать его как основу для размера клетки графика «крестики-нолики». Вход: количество баров диапазона. Выход: среднее значение ATR в ценовых единицах либо 0 при недостатке данных.

//+------------------------------------------------------------------+
//| 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
  }

Использование среднего ATR за период диапазона вместо текущего ATR позволяет учитывать волатильность во время формирования причины, а не в момент входа. Диапазон, сформированный при низкой волатильности, даст меньший размер клетки и, следовательно, больше столбцов, чем диапазон такой же ширины, сформированный при высокой волатильности. Это корректно отражает более частые колебания цены в доступном ценовом пространстве.

Построение столбцов «крестики-нолики» по барам OHLC

Назначение: преобразовать данные OHLC торгового диапазона в последовательность столбцов графика «крестики-нолики». Вход: поддержка и сопротивление диапазона, количество баров, размер клетки, коэффициент разворота и выходной массив. Выход: общее количество столбцов; массив "columns[]" заполняется непосредственно.

В MQL5 нет встроенного типа данных для графиков «крестики-нолики». Функция "BuildPFChart()" строит такой график, последовательно просматривая бары диапазона от старых к новым, отслеживая направление и экстремум текущего столбца и создавая новый столбец, когда цена проходит в противоположном направлении расстояние, достаточное для разворота.

//+------------------------------------------------------------------+
//| 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
  }

Логика привязки к сетке — "MathFloor", умноженный на размер клетки и дополненный уровнем поддержки диапазона для максимумов, и "MathCeil" с теми же операциями для минимумов — выравнивает экстремумы столбцов по ближайшим границам клеток относительно поддержки диапазона. Это необходимо для сопоставимости столбцов при подсчёте на заданном ценовом уровне. Без привязки к сетке вычисления с плавающей точкой могут привести к непоследовательному расположению экстремумов относительно уровня.

Примечание по реализации: график «крестики-нолики» восстанавливается по данным OHLC баров, а не по внутридневным тикам. На очень малых таймфреймах один бар может содержать несколько движений, достаточных для разворота, которые не видны на уровне баров. Для этого подхода рекомендуется использовать таймфрейм H4 или выше.

Подсчёт горизонтальной причины

Назначение: определить, сколько столбцов графика «крестики-нолики» проходит через уровень линии подсчёта. Вход: сформированный массив столбцов, их количество и цена линии подсчёта. Выход: количество столбцов, диапазон которых включает линию подсчёта.

//+------------------------------------------------------------------+
//| 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
  }
Столбец проходит через линию подсчёта, если её цена находится между значениями "base" и "extreme" включительно. Направление столбца для этой проверки не имеет значения: восходящие и нисходящие столбцы учитываются одинаково, если охватывают данный уровень. Так измеряется горизонтальная ширина причины на линии подсчёта.

Расчёт цели по графику «крестики-нолики»

Назначение: выполнить полный расчёт цели по графику «крестики-нолики» и вернуть проверенную ценовую цель. Вход: флаг направления, где true означает длинную позицию, а false — короткую. Остальные данные считываются из "g_range". Выход: расчётная ценовая цель либо 0, если результат не прошёл проверку.

//+------------------------------------------------------------------+
//| 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
  }

Функция записывает в журнал все параметры расчёта: размер клетки, общее количество столбцов, горизонтальный подсчёт на линии подсчёта, расчётное движение и итоговую цель. При тестировании эти записи позволяют проверить корректность расчёта для каждой сделки до анализа результатов. Если "CalcPFTarget()" возвращает 0, функции входа используют резервный тейк-профит 2R и фиксируют источник цели в журнале, что позволяет легко разделить две группы сделок для анализа.

Минимальный размер клетки в 3 пункта не позволяет ей стать слишком малой на инструментах с очень низкой волатильностью, что иначе привело бы к нереалистично большому числу столбцов. Верхнего ограничения размера клетки в коде нет, однако проверка "InpMaxTargetPips" не допускает передачи чрезмерно большой цели в торговый приказ.

Распознавание диапазона

Функция "DetectRange()" просматривает последние бары в поиске консолидации, соответствующей требованиям к ширине, высоте и предшествующему тренду. Внутри неё вызываются вспомогательные функции "HasPriorDowntrend()" и "HasPriorUptrend()", которые проверяют направленный контекст перед диапазоном.

//+------------------------------------------------------------------+
//| 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
  }
Обе функции делят период "InpTrendBars" пополам. Первая половина охватывает более раннюю часть периода, а вторая — более позднюю. Нисходящий тренд подтверждается, когда максимум ранней половины заметно выше минимума поздней половины. Для восходящего тренда действует обратное условие.
//+------------------------------------------------------------------+
//| 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() проверяет диапазоны с количеством баров от InpMinRangeBars до InpMaxRangeBars и останавливается на самом коротком корректном варианте. Допуск в 25% позволяет учитывать небольшие выходы цены за границы без отмены диапазона. Здесь же заполняется поле range_atr, после чего оно доступно функции CalcPFTarget().

Распознавание событий Вайкоффа

Каждая функция распознавания анализирует только последний закрытый бар с индексом 1. Все функции работают по одной схеме: сначала проверяется ценовое условие, затем выполняется подтверждение объёмом.

Распознавание спринга — события входа при накоплении:

//+------------------------------------------------------------------+
//| 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
  }

Спринг считается корректным, если минимум опускается ниже поддержки на величину "InpSpringTolerance", а закрытие возвращается внутрь диапазона при тиковом объёме ниже среднего. Объём ниже среднего указывает на отсутствие реального предложения: снижение было выбиванием слабых участников, а не институциональными продажами.

Распознавание признака силы — события продолжения накопления:

//+------------------------------------------------------------------+
//| 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
  }

Признак силы требует закрытия выше сопротивления при тиковом объёме выше среднего. Высокий объём на пробое подтверждает, что движение действительно контролируется спросом. Без подтверждения объёмом закрытие выше сопротивления может оказаться слабым выходом за границу, который быстро развернётся.

Распознавание аптраста — события входа при распределении:

//+------------------------------------------------------------------+
//| 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
  }
Распознавание признака слабости — события продолжения распределения:
//+------------------------------------------------------------------+
//| 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
  }
Аптраст и признак слабости являются структурными зеркалами спринга и признака силы. Аптраст выбивает слабых держателей коротких позиций выше сопротивления на низком объёме, а признак слабости подтверждает контроль продавцов закрытием ниже поддержки на высоком объёме.

Функции входа

После подтверждения признака силы или слабости функции "CheckLPSEntry()" и "CheckLPSYEntry()" вызываются на каждом новом баре. Они ожидают до "InpLPSBars" баров, пока откат не выполнит условия, после чего задают линию подсчёта, рассчитывают цель по графику «крестики-нолики» и открывают позицию.

Вход в длинную позицию в последней точке поддержки:

//+------------------------------------------------------------------+
//| 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
        }
     }
  }

Линия подсчёта устанавливается по цене закрытия последней точки поддержки непосредственно в момент подтверждения отката, а не заранее. Это важно: линия должна отражать фактический уровень начала фазы роста, который неизвестен до закрытия бара отката. Присваивание "close1" полю "g_range.count_line" непосредственно перед вызовом "CalcPFTarget()" гарантирует, что измерение всегда выполняется по правильной цене.

Выбор тейк-профита выполняется одним условием: если цель по графику «крестики-нолики» больше нуля, используется эта проекция; иначе применяется фиксированное соотношение 2R. Метки "(P&F)" и "(2R)" в журнале позволяют легко разделить две группы при анализе.

Вход в короткую позицию в последней точке предложения:

//+------------------------------------------------------------------+
//| 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
        }
     }
  }

Вход в последней точке предложения зеркален входу в последней точке поддержки, но используется для короткой позиции. Вместо "CalcPFTarget(true)" вызывается "CalcPFTarget(false)", а цена Bid используется вместо Ask. Стоп-лосс размещается выше точки входа на расстоянии ATR, умноженного на "InpATRMult" и прибавленного к Bid. Остальная логика идентична.

Обработчики событий

Функция "OnInit()" создаёт хэндл индикатора ATR, настраивает торговый объект и инициализирует все переменные состояния. "OnDeinit()" освобождает хэндл и удаляет объекты с графика. "OnTick()" запускает конечный автомат один раз на каждом новом баре.

//+------------------------------------------------------------------+
//| 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
  }
Один хэндл ATR используется для двух задач: расчёта расстояния до стоп-лосса и усреднения ATR диапазона. Дополнительные хэндлы индикаторов не требуются.
//+------------------------------------------------------------------+
//| 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()" выполняется только один раз на новом баре: проверка "g_last_bar" в начале функции отфильтровывает внутрибаровые тики. Это правильно для распознавания сигналов, поскольку должны анализироваться только завершённые бары. Однако управление позицией — например, корректировка стоп-лосса, трейлинг или перевод в безубыток — при добавлении в будущей версии потребуется вынести за пределы этой проверки. Конечный автомат обрабатывает ровно одно состояние на бар и сразу завершает выполнение. Условия отмены проверяются до перехода, поэтому автомат не может пропустить состояние.

Ключевые решения при реализации

Размер клетки равен 0,25 ATR диапазона, поэтому он адаптируется к волатильности и применим к разным инструментам без привязки к их типу. Минимальный размер в 3 пункта предотвращает чрезмерное количество столбцов на низковолатильных символах. Коэффициент разворота фиксирован на уровне одной клетки, что позволяет учитывать максимальное количество внутренних колебаний диапазона. Линия подсчёта проводится по цене закрытия последней точки поддержки или предложения, что даёт объективную и воспроизводимую замену субъективной визуальной оценке. Резервный тейк-профит 2R применяется только тогда, когда структура «крестики-нолики» слишком узка для корректной проекции. Стоп-лосс размещается на расстоянии 1,5 ATR от входа. Риск на сделку фиксирован на уровне 1% баланса, а размер позиции рассчитывается с учётом стоимости тика. Допуск диапазона составляет 25% его высоты и позволяет учитывать небольшие выходы цены за границы. Порог объёма равен 1,2 среднего значения и применяется как к событиям с высоким, так и с низким объёмом.

Тестирование

Для тестирования советника откройте Тестер стратегий MetaTrader 5 и задайте следующие параметры. Символ: EURUSD. Таймфрейм: H4. Режим моделирования: каждый тик на основе реальных тиков. Начальный депозит: 10 000 долларов. Период тестирования: с 01.04.2025 по 01.04.2026. Используйте те же параметры диапазона и объёма, что и в Части 1. Для настроек «крестики-нолики» начните со значений "InpPFReversalBoxes" = 1, "InpMinTargetPips" = 30 и "InpMaxTargetPips" = 500.

Чего ожидать

В журнале подробно фиксируется каждый расчёт по графику «крестики-нолики»: размер клетки на основе ATR, общее число столбцов, горизонтальный подсчёт на линии подсчёта, расчётное движение и итоговая цель. Эти данные позволяют убедиться в корректности расчёта до того, как делать выводы по результатам.

Для сделок с корректной целью по графику «крестики-нолики» в журнале отображается "TP:%.5f (P&F)". Если структура оказалась слишком узкой, отображается "TP:%.5f (2R)", что указывает на использование резервной цели. Отслеживайте эти группы отдельно, чтобы сравнить результаты сделок со структурной целью и резервным тейк-профитом.

Если цель по графику «крестики-нолики» систематически оказывается ниже фактического движения цены, размер клетки может быть слишком большим — увеличьте множитель ATR с 0,25 в направлении 0,35. Если цель постоянно завышена, уменьшите множитель в направлении 0,20.

Результаты тестирования

cause and effect demonstration

Рисунок 2. Демонстрация тестирования.

graph

Рисунок 3. Кривые баланса и средств на счёте.

results

Рисунок 4. Результаты тестирования.

entries

Рисунок 5. Входы в ходе тестирования.


Известные ограничения

Размер клетки графика «крестики-нолики» рассчитывается по ATR, а не по абсолютному уровню цены. Такой подход не зависит от инструмента, но размер клетки изменяется вместе с волатильностью. Диапазон, сформированный при низкой волатильности, будет иметь меньшую клетку и больше столбцов, чем диапазон той же ширины при высокой волатильности. Поэтому итоговые цели будут различаться. Математически это корректно: подсчёт чувствителен к масштабу измерения. Однако цели нельзя напрямую сравнивать между различными рыночными условиями.

Линия подсчёта устанавливается по цене закрытия LPS или LPSY, а не по вручную определённому структурному уровню последней точки поддержки или последней точки предложения. В исходной методике Вайкоффа линия проводится по конкретному структурному уровню, выбор которого требует визуальной интерпретации. Советник приближённо реализует это, используя в качестве линии подсчёта цену закрытия бара отката. Такой подход обеспечивает консервативное и объективное измерение, однако выбранный уровень может отличаться от решения опытного аналитика по Вайкоффу.

Советник во всех расчётах использует разворот на одну клетку. Для долгосрочных подсчётов специалисты по Вайкоффу иногда применяют разворот на три клетки. Выбор коэффициента разворота существенно влияет на количество столбцов и, следовательно, на расчётную цель. В будущем можно добавить оба варианта подсчёта и использовать их среднее значение как целевую зону.

При построении графика «крестики-нолики» анализируются данные OHLC на уровне баров, а не внутридневные тики. На очень малых таймфреймах отдельные бары могут содержать несколько движений, достаточных для разворота, но невидимых на уровне баров. Для этого подхода рекомендуется использовать H4 и выше. На H1 результаты следует тщательно сверять с визуальным анализом графика «крестики-нолики».

Заключение

Закон причины и следствия Вайкоффа — одна из самых изящных идей технического анализа: величина тренда пропорциональна размеру предшествующей консолидации. Подсчёт по графику «крестики-нолики» превращает эту идею в конкретное число.

В Части 1 этой серии была создана архитектура распознавания и входа. В данной статье добавлен измерительный уровень, благодаря которому выход определяется структурой, а не произвольным коэффициентом. Входя в сделку в последней точке поддержки, советник теперь знает ширину диапазона накопления, количество сформированных столбцов «крестики-нолики» и расчётную протяжённость последующей фазы роста. Тейк-профит больше не является простым множителем расстояния до стоп-лосса — он представляет собой измерение причины.

Вместе Часть 1, посвящённая входу, и Часть 2, посвящённая выходу, образуют полноценную систему на основе метода Вайкоффа. Советник входит в точке с минимальным риском перед фазой роста и выходит на уровне расчётного следствия накопленной причины. Оба решения основаны на структуре цены, а не на внешних индикаторах.

Весь код скомпилирован и протестирован в MetaTrader 5. Полный исходный файл "WyckoffCauseEffectEA.mq5" приложен к статье. Скопируйте его в "MQL5\Experts\WyckoffCauseEffectEA.mq5" и скомпилируйте в MetaEditor; дополнительные зависимости не требуются. Рекомендуемые условия — EURUSD, H4. Перед использованием на реальном счёте обязательно протестируйте советник на демо-счёте.

Перевод с английского произведен MetaQuotes Ltd.
Оригинальная статья: https://www.mql5.com/en/articles/22472

Прикрепленные файлы |
Анализ CSV-данных (Часть 5):  Потоковая передача CSV-данных в реальном времени из активных сессий MetaTrader 5 Анализ CSV-данных (Часть 5): Потоковая передача CSV-данных в реальном времени из активных сессий MetaTrader 5
В статье описывается фреймворк экспорта данных в реальном времени для MetaTrader 5, построенный на основе слабо связанной трёхуровневой архитектуры. Компонент MQL5 пакетирует записи по барам и тикам с помощью буфера записи и ежедневно выполняет ротацию CSV-файлов; демон Python читает поток дописываемых данных в режиме tail, отображает панель мониторинга в реальном времени и отмечает превышения порогов аномалий. Демонстрационный индикатор показывает точки интеграции, обеспечивая мониторинг в реальном времени, прозрачность и прослеживаемость во время торговых сессий.
Встраивание торговой дисциплины в код (Часть 7): Автоматизация защиты эквити с помощью логики управления Встраивание торговой дисциплины в код (Часть 7): Автоматизация защиты эквити с помощью логики управления
Автоматизированные торговые системы часто уделяют большое внимание генерации сигналов, пренебрегая механизмами, необходимыми для защиты капитала в периоды стресса. В данной статье представлен фреймворк управления эквити в MQL5, который отслеживает условия просадки, оценивает давление на эквити и динамически управляет торговой активностью с помощью модели управления рисками, основанной на состояниях. Объединяя анализ просадки, логику охлаждения, авторизацию сделок и ограничения на исполнение, эта структура демонстрирует, как торговая дисциплина может быть непосредственно встроена в код с использованием модульной и расширяемой архитектуры.
Особенности написания экспертов Особенности написания экспертов
Написание и тестирование экспертов в торговой системе MetaTrader 4.
Создание торговых систем с искусственным интеллектом на MQL5 (Часть 3): Переход к прокручиваемому интерфейсу с единым окном чата Создание торговых систем с искусственным интеллектом на MQL5 (Часть 3): Переход к прокручиваемому интерфейсу с единым окном чата
В этой статье мы дорабатываем программу на языке MQL5 с интеграцией ChatGPT, добавляя прокручиваемый интерфейс с единым окном чата, отметками времени и динамической прокруткой истории диалогов. Система использует разбор JSON для обработки сообщений в многоходовом диалоге и поддерживает настраиваемые режимы полосы прокрутки и эффекты наведения, которые повышают удобство работы.