Автоматизация графических паттернов в MQL5 (Часть 2): Паттерны "двойная вершина" и "двойное дно"
Введение
В первой части данной серии мы создали CSwingEngine — класс с возможностью повторного использования, который определяет значимые свинговые точки на H4, присваивает каждой метку HH, LH, HL или LL и классифицирует рыночную структуру как восходящий тренд, нисходящий тренд или диапазон. Таймфрейм графика, на котором работает использующий этот класс советник или индикатор, на результат не влияет. В этой статье мы применим движок к первой паре паттернов данной серии — "двойной вершине" и "двойному дну". Алгоритм использует движок для подтверждения тренда на H4, а затем проверяет, соответствуют ли последние два подтвержденных свинговых максимума или минимума на H4 структурным требованиям паттерна. Триггером входа служит пробой линии шеи, который определяется на таймфрейме графика советника. Уровень тейк-профита рассчитывается по проекции высоты паттерна.
"Двойная вершина" — один из наиболее обсуждаемых разворотных паттернов в техническом анализе. Этот паттерн возникает, когда цена дважды достигает одного и того же уровня сопротивления, в обоих случаях не может пробить его вверх, а затем пробивает минимум между двумя вершинами. "Двойное дно" — зеркальное отражение этой конструкции: цена дважды тестирует один и тот же уровень поддержки, после чего пробивает вверх максимум между двумя впадинами.
Этот паттерн легко описать. Проблема в том, что алгоритмы, учитывающие только форму паттерна, срабатывают постоянно — при восходящем и нисходящем тренде и в диапазоне, везде, где цены двух баров случайно оказываются примерно на одном уровне. Грамотно построенный алгоритм обнаружения "двойной вершины" начинает не с поиска двух равных максимумов. Сначала он проверяет, предшествовал ли паттерну восходящий тренд. Только после подтверждения тренда алгоритм проверяет, образуют ли последние два свинговых максимума структуру, соответствующую требованиям паттерна "двойная вершина".
Эта последовательность — сначала контекст, затем форма — лежит в основе архитектуры обнаружения всех паттернов в серии. В этой статье для "двойной вершины" требуется состояние TREND_UP, а для "двойного дна" — TREND_DOWN. Эти состояния всегда определяются по структуре на H4 независимо от того, на каком графике работает советник. Если это условие не выполнено, форма паттерна вообще не анализируется. Сигнал не может быть сформирован вне соответствующего структурного контекста.
В этой архитектуре есть и второй принцип: тренд проверяется по H4, а пробой линии шеи — непосредственный сигнал для входа — определяется на таймфрейме графика советника. По паттерну "двойная вершина", подтвержденному структурой на H4, можно войти при закрытии бара H1 или M15 ниже линии шеи. Это позволяет войти существенно раньше и точнее, чем при ожидании закрытия бара H4. Контекст определяется на старшем таймфрейме. Момент входа определяется на таймфрейме, на котором вы торгуете.
Третий принцип касается жизненного цикла паттерна: он обнаруживается один раз. Дальнейшая логика определяется состоянием системы, а не повторным обнаружением паттерна. Подтвержденный паттерн фиксируется и отслеживается на последующих барах до пробоя линии шеи или истечения срока действия. Повторная сделка по нему не совершается. Это важно, поскольку базовая структура на H4 меняется гораздо реже, чем структура на таймфрейме графика. Без явно заданного жизненного цикла один и тот же паттерн мог бы повторно проходить проверку и приводить к нескольким входам.
Прежде чем продолжить, уточним: во всех упоминаниях H4 в этой статье подразумевается значение параметра InpSwingTF по умолчанию. Таймфрейм свингов задается входным параметром, а не фиксируется в коде. Если установить для InpSwingTF значение PERIOD_D1 или PERIOD_H1, под H4 далее следует понимать выбранный вами таймфрейм свингов.
Основной вопрос этой статьи:
После подтвержденного тренда на H4 протестировала ли цена значимый уровень дважды, сформировав на H4 два почти равных экстремума, и произошел ли на моем торговом таймфрейме достаточно сильный пробой линии шеи, чтобы подтвердить разворот?
Мы рассмотрим следующие темы:
- "Двойная вершина" и "двойное дно" — теория и измерение
- Условия допустимости паттерна в этой реализации
- Архитектура — два файла, три состояния
- Реализация на MQL5
- Известные ограничения
- Заключение
"Двойная вершина" и "двойное дно" — теория и измерение
Паттерн "двойная вершина" состоит из пяти структурных компонентов.

Рис. 1. "Двойная вершина" — структура и измерение
Предшествующий тренд — восходящее движение на H4 перед формированием паттерна. Без него у паттерна нет необходимого контекста. До начала проверки "двойной вершины" CSwingEngine должен подтвердить восходящий тренд: последний свинговый максимум на H4 должен иметь метку HH, а последний свинговый минимум на H4 — HL.
Первая вершина — это последний свинговый максимум восходящего тренда на H4: цена достигла этого уровня, но не смогла на нем удержаться. Движок сохраняет ее как последний подтвержденный свинговый максимум.
Впадина — это свинговый минимум на H4, расположенный между двумя вершинами. Она задает линию шеи. Линия шеи — это горизонтальный уровень, который цена должна пробить вниз, чтобы подтвердить разворот. Движок сохраняет впадину как свинговый минимум, возникший после первой вершины.
Вторая вершина — это последующий свинговый максимум на H4 примерно на том же ценовом уровне, что и первая вершина. Допуск для приблизительного равенства настраивается. По умолчанию он составляет 1,5 ATR; значение ATR берется с H4 — того же таймфрейма, на котором определяются вершины. Если разница цен двух вершин не превышает 1,5 ATR на H4, структура проходит проверку.
Пробой линии шеи служит сигналом для входа. В отличие от определения тренда и вершин, проверка пробоя выполняется на таймфрейме графика советника. Когда бар на таймфрейме графика советника закрывается ниже линии шеи, паттерн считается завершенным и открывается позиция.
Целевой уровень рассчитывается проекцией высоты паттерна вниз от линии шеи. Высота — расстояние от линии шеи до уровня вершин. Если вершины находятся на уровне 1,1000, а линия шеи — на уровне 1,0850, высота паттерна составляет 150 пипсов. Целевой уровень — 1,0850 минус 150 пипсов, то есть 1,0700.

Рис. 2. "Двойное дно" — структура и измерение
В паттерне "двойное дно" все пять компонентов зеркально отражены. Движок подтверждает предшествующий нисходящий тренд на H4. Две впадины на H4 находятся примерно на одном ценовом уровне. Линия шеи проходит на уровне вершины между двумя впадинами. Пробой линии шеи вверх на таймфрейме текущего графика служит сигналом для входа. Целевой уровень рассчитывается проекцией высоты паттерна вверх от линии шеи.
Условия допустимости паттерна в этой реализации
Перед формированием сигнала алгоритм проверяет выполнение шести условий. Все шесть условий должны быть выполнены одновременно.
- Условие тренда. CSwingEngine должен возвращать TREND_UP для "двойной вершины" или TREND_DOWN для "двойного дна". При этом используется структура на H4 независимо от таймфрейма графика советника. Если движок возвращает TREND_RANGE, дальнейшая проверка паттерна не выполняется.
- Условие количества свинговых точек. В массиве движка должно быть не менее четырех подтвержденных свинговых точек на H4 — как минимум два максимума и два минимума. Если свингов меньше, истории рыночной структуры недостаточно для проверки.
- Условие формирования пары свингов. Алгоритм должен быть способен найти два свинга нужного типа (два максимума для "двойной вершины» или два минимума для "двойного дна"), между которыми находится ровно один свинг противоположного типа. Для этой проверки используется отдельный булев флаг, а не сравнение поля цены с нулем. Свинговая точка с ценой 0.0 нереалистична для любого валютного символа, но использовать еще не заданную цену как признак "пока не найдено" все равно неправильно. Поэтому в приведенном ниже алгоритме поиска отдельный флаг показывает, найдена ли промежуточная свинговая точка.
- Условие равенства точек. Разница их цен не должна превышать произведение InpPeakTolerance и ATR на H4. Допуск по умолчанию составляет 1,5 ATR. Если разница цен превышает этот допуск, это значит, что точки тестируют разные уровни и не образуют допустимый паттерн.
- Условие высоты паттерна. Линия шеи должна находиться ниже обеих точек ("двойная вершина") или выше обеих точек ("двойное дно») как минимум на величину InpMinPatternHeight × ATR на H4. Это обеспечивает достаточную высоту паттерна и исключает почти плоские структуры.
- Условие временного порядка и ширины. Сначала должна сформироваться первая точка, затем свинг, задающий линию шеи, и только после него — вторая точка. Расстояние между первой и второй точками должно составлять от 1 до InpMaxPatternBars баров H4 и определяться по их временным меткам, а не по индексам массива. Расчет по временным меткам, а не по разности внутренних индексов освобождает эту проверку от предположений о порядке элементов в массиве движка. Она использует только поле time, предусмотренное публичным интерфейсом движка.
Выполнение всех шести условий позволяет рассматривать паттерн для входа, но еще не означает открытия позиции. В разделе 3 рассматривается машина состояний, которая управляет переходом от допустимого паттерна к открытой позиции. Там же объясняется, почему одного соответствия структурным условиям недостаточно для входа.
Уточнение об ATR: алгоритм использует один хэндл индикатора ATR, который рассчитывается на таймфрейме свингов H4, а не на таймфрейме графика советника. Допуск между вершинами и высота паттерна относятся к структуре на H4, поэтому их следует оценивать относительно волатильности того же таймфрейма. Если использовать ATR таймфрейма графика, например M15, допуск окажется слишком мал для вершин, определенных на H4, и допустимые паттерны будут отклоняться. Значение ATR проверяется при каждом считывании. Если индикатор еще не накопил достаточно данных для расчета или в данных по символу есть пропуск, он может вернуть ноль или EMPTY_VALUE. Поскольку при всех проверках допуски рассчитываются умножением на ATR, нулевое значение без проверки незаметно отключило бы фильтры расстояний советника.
Архитектура — два файла, три состояния
- DoubleTopEA.mq5 — советник. Он отвечает за совершение сделок, управление позициями, размещение стопов, расчет размера позиции в лотах и отображение меток на графике. Он подключает файл SwingEngine.mqh из первой части и использует движок для получения текущего состояния тренда на H4 и массива свингов. Для наглядности логика проверки паттернов реализована непосредственно в советнике. В последующих статьях, по мере развития серии, она будет вынесена в отдельный подключаемый файл.
- Файл SwingEngine.mqh не изменился по сравнению с первой частью. Для обнаружения "двойной вершины" ничего менять в нем не требуется. Его публичный интерфейс предоставляет все необходимое советнику, в том числе две вспомогательные инфраструктурные функции GetPipValue и PipSize, используемые во всех статьях серии. В версии этой статьи из первой части серии советник содержал локальную копию PipSize(); здесь это дублирование устранено. Теперь советник DoubleTopEA.mq5 напрямую вызывает функции, объявленные в SwingEngine.mqh, поскольку этот файл и так подключается для использования движка. Это избавляет от необходимости отдельно синхронизировать еще одну функцию между файлами.
Помимо внутренней проверки нового бара H4 в движке, советник использует отдельную проверку появления нового бара на таймфрейме своего графика. Проверки структуры паттерна и пробоя линии шеи выполняются один раз на каждом завершенном баре графика. Обнаружение свингов и классификация тренда выполняются один раз после закрытия каждого бара H4 независимо от того, как часто появляется новый бар на таймфрейме графика. Эти две проверки работают независимо, и обе необходимы. Проверка нового бара на таймфрейме графика обеспечивает точность момента входа, а проверка нового бара H4 позволяет рассчитывать структуру стабильно и с небольшими вычислительными затратами.
В советнике явно заданы три состояния.
- DT_STATE_SCANNING — состояние по умолчанию. После закрытия каждого бара графика выполняются обе функции: CheckDoubleTop() и CheckDoubleBottom(). Если одна из них возвращает допустимый паттерн, по которому еще не совершалась сделка с этой второй точкой, паттерн фиксируется в g_active_pattern, а советник переходит в состояние DT_STATE_PATTERN_FORMED. Паттерн идентифицируется по паре признаков: временной метке второй точки и типу паттерна. По этой паре проверяется, совершалась ли по нему сделка, поэтому один и тот же паттерн не может привести к повторному входу, даже если предыдущая позиция уже закрыта.
- DT_STATE_PATTERN_FORMED — в этом состоянии советник хранит один зафиксированный паттерн и выполняет три проверки на каждом баре. Советник повторно проверяет паттерн по текущим данным свингов и отменяет его фиксацию, если проверка не пройдена. Если за InpMaxBreakDelayBars баров линия шеи не пробита, срок действия паттерна истекает. Наконец, советник проверяет пробой линии шеи и пытается открыть позицию.
- DT_STATE_IN_TRADE — советник переходит в это состояние только после фактического открытия позиции. Советник возвращается в состояние DT_STATE_SCANNING только тогда, когда HasOpenPosition() не находит соответствующей позиции. Эта функция напрямую проверяет список открытых позиций терминала по символу и магическому числу. Отдельного булева флага "Есть ли открытая позиция?" нет: наличие позиции определяется исключительно по списку позиций терминала. Поэтому после перезапуска терминала, ручного закрытия позиции или повторного прикрепления советника к графику с уже открытой позицией корректное состояние устанавливается уже на первом тике. Для выбора начального состояния сам обработчик событий OnInit() вызывает функцию HasOpenPosition().
Реализация на MQL5
Советник создается по частям.
Подключаемые файлы, перечисления и входные параметры//+------------------------------------------------------------------+ //| DoubleTopEA.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 Chart Patterns in MQL5 Part 2" #property description "Double Top and Double Bottom with H4 context, LTF entry timing" #property description "Requires SwingEngine.mqh from Part 1" #property description "Swing structure reads InpSwingTF (default H4) internally" #include <Trade\Trade.mqh> #include <ChartPatterns\SwingEngine.mqh> // Swing engine + shared infrastructure helpers from Part 1 //+------------------------------------------------------------------+ //| Input Parameters | //+------------------------------------------------------------------+ input group "=== Swing Engine ===" input int InpSwingStrength = 3; // Swing strength — H4 bars required on each side input int InpSwingLookback = 200; // Maximum H4 bars for swing detection input ENUM_TIMEFRAMES InpSwingTF = PERIOD_H4; // Timeframe swing structure is calculated on input group "=== Pattern Settings ===" input double InpPeakTolerance = 1.5; // Peak/trough equality tolerance, in swing-TF ATR input double InpMinPatternHeight = 1.0; // Minimum pattern height, in swing-TF ATR input int InpMaxPatternBars = 80; // Maximum swing-TF bars between first and second point input int InpMaxBreakDelayBars = 40; // Maximum CHART bars to wait for the neckline break input int InpATRPeriod = 14; // ATR period, computed on InpSwingTF input group "=== Entry and Risk ===" input double InpRiskPercent = 1.0; // Risk per trade as percent of balance input double InpSLBufferATR = 0.5; // Stop loss buffer beyond the pattern extreme, in ATR input bool InpUseMeasuredMove = true; // Use measured move target (false = 2R fallback) input double InpMaxSlipATR = 0.5; // Skip entry if price is already this many ATR past the neckline input group "=== General ===" input int InpMagicNumber = 666001; // Magic number input int InpSlippage = 10; // Slippage in points input bool InpShowLabels = true; // Draw pattern labels on chart input bool InpDebugLog = false; // Log the reason every rejected pattern check fails
Параметр InpMaxBreakDelayBars задает максимальный срок ожидания пробоя линии шеи для зафиксированного паттерна. По истечении этого срока сетап считается устаревшим. Параметр InpMaxSlipATR не позволяет советнику входить, если цена уже ушла слишком далеко за линию шеи. Гэп или быстрое движение могут увести цену далеко за этот уровень еще до подтверждения пробоя закрытием бара на таймфрейме графика советника. При таком запоздалом входе характеристики риска сделки отличаются от тех, на которые рассчитаны стоп-лосс и целевой уровень паттерна.
Параметр InpDebugLog управляет дополнительным выводом в лог во всех функциях обнаружения. Для каждого условия, из-за которого паттерн отклоняется, можно включить вывод причины отказа. Это полезно при настройке параметров или выяснении, почему советник перестал подавать сигналы по символу, на котором, казалось бы, есть условия для их появления.
Результат обнаружения и состояние паттерна
Структура результата содержит отдельный флаг типа паттерна, поэтому вызывающему коду не приходится определять по контексту, идет ли речь о "двойной вершине" или "двойном дне". Состояние самого советника задается перечислением из трех значений, соответствующих трем описанным выше состояниям.
//+------------------------------------------------------------------+ //| Holds the result of a Double Top or Double Bottom check | //+------------------------------------------------------------------+ struct SPatternResult { bool valid; // true if a valid pattern was found bool is_top; // true = Double Top (short setup), false = Double Bottom (long setup) double point1; // Price of the first (older) point — a high for DT, a low for DB double point2; // Price of the second (newer) point double neckline; // Neckline price double target; // Measured move target price double stop; // Stop loss price datetime point1_time; // Time of the first point datetime point2_time; // Time of the second point — also this pattern's unique identifier datetime neck_time; // Time of the neckline swing }; //+------------------------------------------------------------------+ //| Pattern lifecycle — replaces a single "in trade" boolean | //+------------------------------------------------------------------+ enum ENUM_DT_STATE { DT_STATE_SCANNING, // No locked pattern — evaluating every bar DT_STATE_PATTERN_FORMED, // Pattern locked — watching for neckline break or expiry DT_STATE_IN_TRADE // Position open from this pattern };
Поле point2_time также служит идентификатором паттерна. По принятому принципу идентификации два паттерна одного типа с одинаковой временной меткой второй точки считаются одним и тем же паттерном. Движок свингов формирует не более одного подтвержденного свинга заданного направления на каждом баре H4, поэтому у разных паттернов эта временная метка совпасть не может.
Глобальные переменные и хэндлы индикаторов
//+------------------------------------------------------------------+ //| Global Variables | //+------------------------------------------------------------------+ CSwingEngine g_engine; // Swing engine from Part 1 — reads InpSwingTF internally CTrade g_trade; // Trade execution object int g_atr_handle = INVALID_HANDLE; // ATR handle on InpSwingTF — matches the peaks it measures datetime g_last_bar = 0; // Last processed CHART bar time string g_obj_prefix = ""; // Unique per symbol + magic number, set in OnInit() ENUM_DT_STATE g_state = DT_STATE_SCANNING; // Current pattern lifecycle state SPatternResult g_active_pattern; // The one pattern currently locked in int g_pattern_age_bars = 0; // Chart bars since the pattern was locked in datetime g_last_traded_dt_point2 = 0; // Second-point time of the last DOUBLE TOP actually traded datetime g_last_traded_db_point2 = 0; // Second-point time of the last DOUBLE BOTTOM actually traded
Значение g_obj_prefix формируется в обработчике событий OnInit() на основе символа и магического числа. Благодаря этому два экземпляра советника, а также советник и сторонний инструмент, который тоже использует префикс "DT_", не могут создавать графические объекты с совпадающими именами или удалять построения друг друга.
Вспомогательные функции//+------------------------------------------------------------------+ //| Prints a message only when debug logging is enabled | //+------------------------------------------------------------------+ void DebugLog(string msg) { if(InpDebugLog) Print("DoubleTopEA[debug]: ", msg); } //+------------------------------------------------------------------+ //| True if a position matching this symbol and magic number exists | //| The single source of truth for "are we in a trade" — no separate | //| boolean flag is maintained anywhere else in the EA. | //+------------------------------------------------------------------+ bool HasOpenPosition(const string symbol, const long magic) { for(int i = PositionsTotal() - 1; i >= 0; i--) { ulong ticket = PositionGetTicket(i); if(!PositionSelectByTicket(ticket)) continue; if(PositionGetString(POSITION_SYMBOL) != symbol) continue; if(PositionGetInteger(POSITION_MAGIC) != magic) continue; return true; } return false; } //+------------------------------------------------------------------+ //| Computes lot size from risk percent and stop distance in pips | //| If the risk-appropriate size rounds below the broker minimum, | //| the trade is skipped entirely rather than forced up to the | //| minimum lot — forcing it up would silently exceed InpRiskPercent.| //+------------------------------------------------------------------+ double CalcLots(double sl_pips) { double balance = AccountInfoDouble(ACCOUNT_BALANCE); double risk_amt = balance * InpRiskPercent / 100.0; double pip_value = GetPipValue(_Symbol, 1.0); // Money value of 1 pip at 1.0 lot if(pip_value <= 0 || sl_pips <= 0) return 0; double step = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_STEP); double min_lot = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MIN); double max_lot = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MAX); if(step <= 0) return 0; double raw_lots = risk_amt / (sl_pips * pip_value); //--- Round DOWN to the nearest volume step. A small epsilon guards //--- against a step division landing just under a whole number due //--- to ordinary floating-point rounding. double steps = MathFloor(raw_lots / step + 0.0000001); double lots = NormalizeDouble(steps * step, 8); if(lots < min_lot) return 0; // Skip rather than over-risk return MathMin(max_lot, lots); } //+------------------------------------------------------------------+ //| True if free margin covers the position this order would open | //+------------------------------------------------------------------+ bool HasSufficientMargin(ENUM_ORDER_TYPE order_type, double lots, double price) { double margin_required = 0; if(!OrderCalcMargin(order_type, _Symbol, lots, price, margin_required)) return false; return (margin_required <= AccountInfoDouble(ACCOUNT_MARGIN_FREE)); }
Вспомогательные функции для отрисовки на графике
Существующие объекты обновляются без удаления и повторного создания на каждом баре. Функция ChartRedraw() вызывается один раз в конце выполнения обработчика событий OnTick(), а не отдельно для каждого объекта.
//+------------------------------------------------------------------+ //| Creates or moves a horizontal line at the specified price level | //+------------------------------------------------------------------+ void DrawHLine(string name, double price, color clr, ENUM_LINE_STYLE style) { if(!InpShowLabels) return; string obj = g_obj_prefix + name; if(ObjectFind(0, obj) < 0) { if(!ObjectCreate(0, obj, OBJ_HLINE, 0, 0, price)) { DebugLog("ObjectCreate failed for " + obj); return; } } ObjectSetDouble(0, obj, OBJPROP_PRICE, price); ObjectSetInteger(0, obj, OBJPROP_COLOR, clr); ObjectSetInteger(0, obj, OBJPROP_STYLE, style); ObjectSetInteger(0, obj, OBJPROP_WIDTH, 1); } //+------------------------------------------------------------------+ //| Creates or moves a text label at the specified time and price | //+------------------------------------------------------------------+ void DrawLabel(string name, datetime time, double price, string text, color clr) { if(!InpShowLabels) return; string obj = g_obj_prefix + name; if(ObjectFind(0, obj) < 0) { if(!ObjectCreate(0, obj, OBJ_TEXT, 0, time, price)) { DebugLog("ObjectCreate failed for " + obj); return; } } ObjectMove(0, obj, 0, time, price); ObjectSetString(0, obj, OBJPROP_TEXT, text); ObjectSetInteger(0, obj, OBJPROP_COLOR, clr); ObjectSetInteger(0, obj, OBJPROP_FONTSIZE, 9); } //+------------------------------------------------------------------+ //| Deletes a single named object, if it exists | //+------------------------------------------------------------------+ void DeleteObject(string name) { string obj = g_obj_prefix + name; if(ObjectFind(0, obj) >= 0) ObjectDelete(0, obj); } //+------------------------------------------------------------------+ //| Removes every chart object created by this EA instance | //+------------------------------------------------------------------+ void ClearLabels() { int total = ObjectsTotal(0); for(int i = total - 1; i >= 0; i--) { string name = ObjectName(0, i); if(StringFind(name, g_obj_prefix) == 0) ObjectDelete(0, name); } }Поиск свингов без сигнального значения
//+------------------------------------------------------------------+ //| Finds the two most recent swings of one type with a single swing | //| of the opposite type between them. Returns false, rather than | //| relying on a price of 0.0 as a sentinel, when no such triple | //| exists in the current swing array. | //+------------------------------------------------------------------+ bool FindTwoSwingsWithMidpoint(bool want_high, SSwingPoint &point1, SSwingPoint &point2, SSwingPoint &mid) { int swing_count = g_engine.GetSwingCount(); // Cached once — GetSwingCount() is not assumed to be free int found = 0; bool mid_found = false; for(int i = 0; i < swing_count; i++) { SSwingPoint sp = g_engine.GetSwing(i); // Index 0 = most recent, per the engine's contract if(sp.is_high == want_high && found == 0) // Newest matching swing { point2 = sp; found++; } else if(sp.is_high != want_high && found == 1 && !mid_found) // Opposite-type swing between the two { mid = sp; mid_found = true; } else if(sp.is_high == want_high && found == 1 && mid_found) // Older matching swing { point1 = sp; found++; break; } } return (found >= 2 && mid_found); }
Обнаружение двойной вершины
//+------------------------------------------------------------------+ //| Evaluates the current H4 swing structure for a Double Top | //| atr must be computed on InpSwingTF, not the chart timeframe | //+------------------------------------------------------------------+ SPatternResult CheckDoubleTop(double atr) { SPatternResult result; result.valid = false; result.is_top = true; if(g_engine.GetTrend() != TREND_UP) { DebugLog("DT reject: H4 trend is not TREND_UP."); return result; } if(g_engine.GetSwingCount() < 4) { DebugLog("DT reject: fewer than 4 confirmed H4 swings."); return result; } SSwingPoint point1 = {true, 0, 0, 0, ""}; SSwingPoint point2 = {true, 0, 0, 0, ""}; SSwingPoint mid = {false, 0, 0, 0, ""}; if(!FindTwoSwingsWithMidpoint(true, point1, point2, mid)) { DebugLog("DT reject: could not find two highs with a low between them."); return result; } if(point1.time >= mid.time || mid.time >= point2.time) { DebugLog("DT reject: swing time ordering is invalid."); return result; } //--- Width, measured from timestamps rather than an internal array index int bars_between = (int)MathRound((double)(point2.time - point1.time) / PeriodSeconds(InpSwingTF)); if(bars_between <= 0 || bars_between > InpMaxPatternBars) { DebugLog(StringFormat("DT reject: bars_between=%d outside (0, %d].", bars_between, InpMaxPatternBars)); return result; } double point_diff = MathAbs(point1.price - point2.price); if(point_diff > atr * InpPeakTolerance) { DebugLog(StringFormat("DT reject: point difference %.5f exceeds tolerance.", point_diff)); return result; } double neckline = mid.price; double pattern_high = MathMax(point1.price, point2.price); double height = pattern_high - neckline; if(height < atr * InpMinPatternHeight) { DebugLog(StringFormat("DT reject: height %.5f below minimum.", height)); return result; } result.valid = true; result.point1 = point1.price; result.point2 = point2.price; result.neckline = neckline; result.target = neckline - height; // Measured move below neckline result.stop = pattern_high + atr * InpSLBufferATR; // Above the highest point result.point1_time = point1.time; result.point2_time = point2.time; result.neck_time = mid.time; Print(StringFormat( "DoubleTopEA: DT found | P1:%.5f | P2:%.5f | Neck:%.5f | Target:%.5f | Height:%.5f", result.point1, result.point2, result.neckline, result.target, height)); return result; }Обнаружение двойного дна
//+------------------------------------------------------------------+ //| Evaluates the current H4 swing structure for a Double Bottom | //+------------------------------------------------------------------+ SPatternResult CheckDoubleBottom(double atr) { SPatternResult result; result.valid = false; result.is_top = false; if(g_engine.GetTrend() != TREND_DOWN) { DebugLog("DB reject: H4 trend is not TREND_DOWN."); return result; } if(g_engine.GetSwingCount() < 4) { DebugLog("DB reject: fewer than 4 confirmed H4 swings."); return result; } SSwingPoint point1 = {false, 0, 0, 0, ""}; SSwingPoint point2 = {false, 0, 0, 0, ""}; SSwingPoint mid = {true, 0, 0, 0, ""}; if(!FindTwoSwingsWithMidpoint(false, point1, point2, mid)) { DebugLog("DB reject: could not find two lows with a high between them."); return result; } if(point1.time >= mid.time || mid.time >= point2.time) { DebugLog("DB reject: swing time ordering is invalid."); return result; } int bars_between = (int)MathRound((double)(point2.time - point1.time) / PeriodSeconds(InpSwingTF)); if(bars_between <= 0 || bars_between > InpMaxPatternBars) { DebugLog(StringFormat("DB reject: bars_between=%d outside (0, %d].", bars_between, InpMaxPatternBars)); return result; } double point_diff = MathAbs(point1.price - point2.price); if(point_diff > atr * InpPeakTolerance) { DebugLog(StringFormat("DB reject: point difference %.5f exceeds tolerance.", point_diff)); return result; } double neckline = mid.price; double pattern_low = MathMin(point1.price, point2.price); double height = neckline - pattern_low; if(height < atr * InpMinPatternHeight) { DebugLog(StringFormat("DB reject: height %.5f below minimum.", height)); return result; } result.valid = true; result.point1 = point1.price; result.point2 = point2.price; result.neckline = neckline; result.target = neckline + height; // Measured move above neckline result.stop = pattern_low - atr * InpSLBufferATR; // Below the lowest point result.point1_time = point1.time; result.point2_time = point2.time; result.neck_time = mid.time; Print(StringFormat( "DoubleTopEA: DB found | P1:%.5f | P2:%.5f | Neck:%.5f | Target:%.5f | Height:%.5f", result.point1, result.point2, result.neckline, result.target, height)); return result; }
Открытие позиции
Обе функции открытия позиции повторно вызывают HasOpenPosition() непосредственно перед отправкой ордера, проверяют достаточность маржи, нормализуют цены и учитывают установленные брокером минимальную дистанцию до стопов и дистанцию заморозки. Они также отклоняют вход, если цена ушла за линию шеи настолько далеко, что исполнение ордера по цене, соответствующей структуре паттерна, уже невозможно.
//+------------------------------------------------------------------+ //| Opens a short trade on a confirmed Double Top neckline break | //+------------------------------------------------------------------+ void TryEntryShort(const SPatternResult &pat, double atr) { if(HasOpenPosition(_Symbol, InpMagicNumber)) return; // Belt-and-braces position check double bid = SymbolInfoDouble(_Symbol, SYMBOL_BID); //--- Do not chase a neckline that has already been broken by a wide margin if(bid < pat.neckline - atr * InpMaxSlipATR) { DebugLog("DT entry skipped — price already too far past the neckline."); return; } double sl = NormalizeDouble(pat.stop, _Digits); double tp = NormalizeDouble(InpUseMeasuredMove ? pat.target : bid - (sl - bid) * 2.0, _Digits); double sl_pip = (sl - bid) / PipSize(_Symbol); double lots = CalcLots(sl_pip); if(lots <= 0) { DebugLog("DT entry skipped — risk-appropriate lot size is below the broker minimum."); return; } long stops_level = SymbolInfoInteger(_Symbol, SYMBOL_TRADE_STOPS_LEVEL); long freeze_level = SymbolInfoInteger(_Symbol, SYMBOL_TRADE_FREEZE_LEVEL); double min_dist = MathMax(stops_level, freeze_level) * _Point; if(sl - bid < min_dist) sl = NormalizeDouble(bid + min_dist + _Point, _Digits); if(bid - tp < min_dist) tp = NormalizeDouble(bid - min_dist - _Point, _Digits); if(!HasSufficientMargin(ORDER_TYPE_SELL, lots, bid)) { DebugLog("DT entry skipped — insufficient free margin."); return; } //--- Price 0.0 lets CTrade fill at the current market price rather than //--- the bid snapshot taken a few lines above, avoiding an avoidable //--- rejection if the price has ticked in the meantime. if(g_trade.Sell(lots, _Symbol, 0.0, sl, tp, "Double Top")) { DrawHLine("NECKLINE", pat.neckline, clrOrange, STYLE_DASH); DrawHLine("TARGET", tp, clrGold, STYLE_DOT); DrawHLine("STOP", sl, clrCrimson, STYLE_DOT); datetime t = iTime(_Symbol, PERIOD_CURRENT, 1); DrawLabel("ENTRY", t, iHigh(_Symbol, PERIOD_CURRENT, 1) + PipSize(_Symbol) * 5, "DT", clrCrimson); Print(StringFormat("DoubleTopEA: SHORT | Lots:%.2f | Bid:%.5f | SL:%.5f | TP:%.5f", lots, bid, sl, tp)); g_last_traded_dt_point2 = pat.point2_time; // This exact pattern cannot re-trade g_state = DT_STATE_IN_TRADE; } else Print("DoubleTopEA: Sell failed | Retcode:", g_trade.ResultRetcode()); } //+------------------------------------------------------------------+ //| Opens a long trade on a confirmed Double Bottom neckline break | //+------------------------------------------------------------------+ void TryEntryLong(const SPatternResult &pat, double atr) { if(HasOpenPosition(_Symbol, InpMagicNumber)) return; double ask = SymbolInfoDouble(_Symbol, SYMBOL_ASK); if(ask > pat.neckline + atr * InpMaxSlipATR) { DebugLog("DB entry skipped — price already too far past the neckline."); return; } double sl = NormalizeDouble(pat.stop, _Digits); double tp = NormalizeDouble(InpUseMeasuredMove ? pat.target : ask + (ask - sl) * 2.0, _Digits); double sl_pip = (ask - sl) / PipSize(_Symbol); double lots = CalcLots(sl_pip); if(lots <= 0) { DebugLog("DB entry skipped — risk-appropriate lot size is below the broker minimum."); return; } long stops_level = SymbolInfoInteger(_Symbol, SYMBOL_TRADE_STOPS_LEVEL); long freeze_level = SymbolInfoInteger(_Symbol, SYMBOL_TRADE_FREEZE_LEVEL); double min_dist = MathMax(stops_level, freeze_level) * _Point; if(ask - sl < min_dist) sl = NormalizeDouble(ask - min_dist - _Point, _Digits); if(tp - ask < min_dist) tp = NormalizeDouble(ask + min_dist + _Point, _Digits); if(!HasSufficientMargin(ORDER_TYPE_BUY, lots, ask)) { DebugLog("DB entry skipped — insufficient free margin."); return; } if(g_trade.Buy(lots, _Symbol, 0.0, sl, tp, "Double Bottom")) { DrawHLine("NECKLINE", pat.neckline, clrOrange, STYLE_DASH); DrawHLine("TARGET", tp, clrGold, STYLE_DOT); DrawHLine("STOP", sl, clrDodgerBlue, STYLE_DOT); datetime t = iTime(_Symbol, PERIOD_CURRENT, 1); DrawLabel("ENTRY", t, iLow(_Symbol, PERIOD_CURRENT, 1) - PipSize(_Symbol) * 5, "DB", clrDodgerBlue); Print(StringFormat("DoubleTopEA: LONG | Lots:%.2f | Ask:%.5f | SL:%.5f | TP:%.5f", lots, ask, sl, tp)); g_last_traded_db_point2 = pat.point2_time; g_state = DT_STATE_IN_TRADE; } else Print("DoubleTopEA: Buy failed | Retcode:", g_trade.ResultRetcode()); }OnInit и OnDeinit
Входные параметры проверяются в самом начале. Префикс имен объектов формируется на основе символа и магического числа. Начальное состояние советника определяется проверкой наличия открытой позиции, а не предположением, что позиции нет. Это важно при повторном прикреплении советника к графику, если до перезапуска уже была открыта позиция и она остается открытой.
//+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit() { if(InpSwingStrength <= 0 || InpSwingLookback <= 0 || InpATRPeriod <= 0 || InpPeakTolerance <= 0 || InpMinPatternHeight <= 0 || InpRiskPercent <= 0 || InpSLBufferATR < 0 || InpMaxPatternBars <= 0 || InpMaxBreakDelayBars <= 0 || InpMaxSlipATR < 0) { Print("DoubleTopEA: one or more input parameters is out of range."); return INIT_PARAMETERS_INCORRECT; } g_obj_prefix = StringFormat("DT_%s_%d_", _Symbol, InpMagicNumber); if(!g_engine.Init(InpSwingStrength, InpSwingLookback, InpSwingTF)) { Print("DoubleTopEA: swing engine initialization failed."); return INIT_FAILED; } g_atr_handle = iATR(_Symbol, InpSwingTF, InpATRPeriod); if(g_atr_handle == INVALID_HANDLE) { Print("DoubleTopEA: ATR handle creation failed."); return INIT_FAILED; } g_trade.SetExpertMagicNumber(InpMagicNumber); g_trade.SetDeviationInPoints(InpSlippage); //--- Decide the starting state from the actual position list, not an assumption g_state = HasOpenPosition(_Symbol, InpMagicNumber) ? DT_STATE_IN_TRADE : DT_STATE_SCANNING; g_pattern_age_bars = 0; g_last_bar = 0; g_last_traded_dt_point2 = 0; g_last_traded_db_point2 = 0; Print(StringFormat( "DoubleTopEA initialized | Symbol:%s | ChartTF:%s | SwingTF:%s | Magic:%d | StartState:%s", _Symbol, EnumToString(Period()), EnumToString(InpSwingTF), InpMagicNumber, (g_state == DT_STATE_IN_TRADE ? "IN_TRADE (recovered)" : "SCANNING"))); return INIT_SUCCEEDED; } //+------------------------------------------------------------------+ //| Expert deinitialization function | //+------------------------------------------------------------------+ void OnDeinit(const int reason) { if(g_atr_handle != INVALID_HANDLE) IndicatorRelease(g_atr_handle); ClearLabels(); }
OnTick — машина состояний паттерна
Здесь реализована логика работы в трех состояниях, описанных в разделе 3. Каждая ветвь заканчивается одним вызовом функции ChartRedraw(), а не отдельным вызовом после каждой операции отрисовки.
//+------------------------------------------------------------------+ //| Expert tick function | //+------------------------------------------------------------------+ void OnTick() { datetime current_bar = iTime(_Symbol, PERIOD_CURRENT, 0); if(current_bar == g_last_bar) return; // One run per chart bar g_last_bar = current_bar; if(Bars(_Symbol, PERIOD_CURRENT) < 5) return; // Chart history not ready yet g_engine.Update(); // Self-gated on a new swing-TF bar double atr_buf[]; ArraySetAsSeries(atr_buf, true); if(CopyBuffer(g_atr_handle, 0, 1, 1, atr_buf) < 1) return; double atr = atr_buf[0]; if(atr <= 0 || atr == EMPTY_VALUE) { DebugLog("ATR invalid on this bar — skipping."); return; } double close1 = iClose(_Symbol, PERIOD_CURRENT, 1); // Last CHART bar close switch(g_state) { case DT_STATE_IN_TRADE: { if(!HasOpenPosition(_Symbol, InpMagicNumber)) { Print("DoubleTopEA: position closed — resuming scan."); g_state = DT_STATE_SCANNING; ClearLabels(); } break; } case DT_STATE_PATTERN_FORMED: { g_pattern_age_bars++; //--- Revalidate: does this exact pattern still hold given the latest swing data? SPatternResult recheck = g_active_pattern.is_top ? CheckDoubleTop(atr) : CheckDoubleBottom(atr); bool same_pattern = recheck.valid && recheck.is_top == g_active_pattern.is_top && recheck.point2_time == g_active_pattern.point2_time; if(!same_pattern) { DebugLog("Locked pattern no longer valid against current swing data — discarding."); DeleteObject("NECKLINE_WATCH"); g_state = DT_STATE_SCANNING; break; } if(g_pattern_age_bars > InpMaxBreakDelayBars) { DebugLog("Locked pattern expired before the neckline broke — discarding."); DeleteObject("NECKLINE_WATCH"); g_state = DT_STATE_SCANNING; break; } DrawHLine("NECKLINE_WATCH", g_active_pattern.neckline, clrOrange, STYLE_DASH); bool broken = g_active_pattern.is_top ? (close1 < g_active_pattern.neckline) : (close1 > g_active_pattern.neckline); if(broken) { Print(StringFormat("DoubleTopEA: %s neckline broken at %.5f", g_active_pattern.is_top ? "DT" : "DB", close1)); DeleteObject("NECKLINE_WATCH"); if(g_active_pattern.is_top) TryEntryShort(g_active_pattern, atr); else TryEntryLong(g_active_pattern, atr); } break; } case DT_STATE_SCANNING: { if(HasOpenPosition(_Symbol, InpMagicNumber)) // Safety net — should be rare { g_state = DT_STATE_IN_TRADE; break; } SPatternResult dt = CheckDoubleTop(atr); if(dt.valid && dt.point2_time != g_last_traded_dt_point2) { g_active_pattern = dt; g_pattern_age_bars = 0; g_state = DT_STATE_PATTERN_FORMED; break; // Double Top takes priority when both qualify } SPatternResult db = CheckDoubleBottom(atr); if(db.valid && db.point2_time != g_last_traded_db_point2) { g_active_pattern = db; g_pattern_age_bars = 0; g_state = DT_STATE_PATTERN_FORMED; } break; } } ChartRedraw(0); // One redraw per bar, not per object } //+------------------------------------------------------------------+
В состоянии DT_STATE_SCANNING советник фиксирует только один паттерн и сразу переходит в состояние DT_STATE_PATTERN_FORMED. Поэтому на графике не могут одновременно отображаться линии ожидания пробоя для "двойной вершины" и "двойного дна": активен только один паттерн и отображается только одна линия NECKLINE_WATCH.
Что дает движок свингов
Чтобы понять, что дает движок алгоритму обнаружения, рассмотрим случай, когда функция GetTrend() возвращает TREND_RANGE. Функция CheckDoubleTop() сразу завершает работу на первой строчке. Проверка шести структурных условий не выполняется. Массив свингов не просматривается. Метки не отрисовываются.
Без этого фильтра алгоритм часто срабатывал бы при боковом движении, когда максимумы двух баров случайно оказываются примерно на одном уровне. Именно такие ложные сигналы делают простейшие алгоритмы обнаружения "двойной вершины" ненадежными. При использовании фильтра алгоритм проверяет структуру паттерна только после того, как рынок продемонстрирует направленное движение, сформировав как минимум два последовательных более высоких максимума и два последовательных более высоких минимума. Именно эта предшествующая структура придает последующему развороту значимость.
Чего ожидать
Паттерны "двойная вершина" и "двойное дно" на H4 встречаются нечасто. По EURUSD следует ожидать от пяти до 15 сигналов в год, соответствующих заданным условиям. Так и должно быть: фильтр из шести условий специально настроен на строгий отбор. В журнал записываются результаты каждой проверки паттерна. Когда движок возвращает TREND_UP, фиксируются две найденные вершины, уровень линии шеи и результаты проверок равенства точек и высоты паттерна. Эта диагностическая информация позволяет убедиться, что алгоритм находит полноценные паттерны, а не структуры на грани допустимости.
Если в журнале часто появляются записи "DT found", сразу после которых открываются позиции, хотя на графике не видно паттерна, уменьшите InpPeakTolerance до 1,0 или InpMaxPatternBars до 50. Если алгоритм вообще не находит паттернов, увеличьте InpSwingLookback до 400 или уменьшите InpSwingStrength до 2.

Рис. 3. Наглядная демонстрация работы системы
Известные ограничения
За один проход алгоритм фиксирует не более одного потенциального паттерна "двойная вершина" или "двойное дно". Выбирается тот тип паттерна, который проверяется первым: если оба паттерна одновременно соответствуют условиям, выбирается "двойная вершина", а не "двойное дно". Он не перебирает все возможные комбинации свинговых точек в поисках наиболее подходящего паттерна, а учитывает только два последних подходящих свинга каждого типа. Поиск среди нескольких возможных пар свингов иногда позволил бы найти структурно более значимый паттерн, чем последний по времени, но существенно усложнил бы алгоритм.
Допуск равенства точек (InpPeakTolerance) симметричен: вторая точка проходит проверку как при превышении уровня первой на величину допуска, так и при отклонении вниз на ту же величину. Некоторые трейдеры, торгующие по паттерну "двойная вершина", предпочитают асимметричное правило: вторая вершина не должна существенно превышать первую, поскольку выраженный более высокий максимум, можно утверждать, противоречит предположению о развороте. В данной реализации не проводится различие между этими двумя случаями.
Размер позиции рассчитывается по расстоянию до стоп-лосса, определенному структурой паттерна. Затраты на спред отдельно не учитываются при оценке цели, рассчитанной по проекции высоты паттерна. На основных валютных парах с узким спредом и таймфреймах H1 и старше этот эффект невелик. На символах с более широким спредом или на младших таймфреймах графика фактическое соотношение вознаграждения и риска будет ниже расчетного соотношения, определяемого структурой паттерна.
Значение InpMaxPatternBars и ширина, проверяемая в CheckDoubleTop()/CheckDoubleBottom(), измеряются в барах H4 (или другого таймфрейма, заданного в InpSwingTF), а не в барах графика. Это связано с тем, что ширина определяется по временным меткам свингов на таймфрейме свингов. Параметр InpMaxBreakDelayBars, напротив, задает срок ожидания в барах графика — того таймфрейма, на котором советник отслеживает пробой. Если при настройке советника перепутать эти две единицы измерения, ограничение окажется либо гораздо более мягким, либо гораздо более жестким, чем предполагалось.
Для подтверждения пробоя бар графика должен закрыться за линией шеи. На неликвидных инструментах или очень малых таймфреймах графика цена часто пересекает линию шеи внутри бара, а затем возвращается обратно. Требование закрытия за линией шеи отсеивает большинство таких случаев, однако на малом таймфрейме, например M5, подобного шума все равно больше, чем на H1.
Советник устроен так, что одновременно отслеживает только одну позицию. В состоянии DT_STATE_IN_TRADE поиск паттернов полностью прекращается до ее закрытия. Другой паттерн, сформировавшийся при уже открытой позиции, не фиксируется, даже если соответствует всем условиям. После возобновления поиска он должен заново пройти полную проверку.
Заключение
"Двойная вершина" и "двойное дно" — одни из старейших и наиболее обсуждаемых паттернов в техническом анализе. Причина столь широкого обсуждения этих паттернов объясняет и неудачи простейших алгоритмов их обнаружения. Внешне паттерны просты, но их допустимость зависит от контекста формирования и таймфрейма, на котором он определяется. Кроме того, подтвержденный паттерн нужно рассматривать как отдельное событие для одной сделки, а не как условие, которое проверяется заново на каждом баре и может приводить к повторным входам.
В данной реализации соблюдаются все три принципа. Движок свингов из первой части классифицирует рыночную структуру на H4 независимо от того, на каком графике работает советник. Проверка шести условий определяет, соответствует ли структура паттерна требованиям на таймфрейме H4. Жизненный цикл паттерна с тремя состояниями превращает "есть допустимый паттерн" в "по этому конкретному паттерну позиция открыта ровно один раз". Советник отслеживает зафиксированный паттерн на протяжении стольких баров, сколько потребуется, отбрасывает его при изменении базовой структуры или истечении срока ожидания и не допускает повторной сделки по той же второй точке. Сам пробой линии шеи по-прежнему определяется с точностью таймфрейма графика.
Весь код был скомпилирован и протестирован в MetaTrader 5. Сохраните файл SwingEngine.mqh из первой части по пути MQL5\Include\ChartPatterns\SwingEngine.mqh. Сохраните файл DoubleTopEA.mq5 по пути MQL5\Experts\DoubleTopEA.mq5. Скомпилируйте советник в MetaEditor; дополнительные зависимости не требуются. Рекомендуется для EURUSD и GBPUSD на H4. Перед запуском на реальном счете всегда проводите тестирование на демо-счете.
Перевод с английского произведен MetaQuotes Ltd.
Оригинальная статья: https://www.mql5.com/en/articles/23540
Предупреждение: все права на данные материалы принадлежат MetaQuotes Ltd. Полная или частичная перепечатка запрещена.
Данная статья написана пользователем сайта и отражает его личную точку зрения. Компания MetaQuotes Ltd не несет ответственности за достоверность представленной информации, а также за возможные последствия использования описанных решений, стратегий или рекомендаций.
Создание графической библиотеки для MetaTrader 5 по образцу Cairo (Часть 2): Точки, контуры и путь
Как обнаруживать и нормализовать объекты графика в MQL5 (часть 1): Создание движка обнаружения объектов графика
Алгоритм опыления цветов — Flower Pollination Algorithm (FPA)
Создание графической библиотеки для MetaTrader 5 по образцу Cairo (Часть 1): Зачем терминалу собственный 2D-рендерер
- Бесплатные приложения для трейдинга
- 8 000+ сигналов для копирования
- Экономические новости для анализа финансовых рынков
Вы принимаете политику сайта и условия использования