preview
Automating Chart Patterns in MQL5 (Part 2): The Double Top and Double Bottom

Automating Chart Patterns in MQL5 (Part 2): The Double Top and Double Bottom

MetaTrader 5Trading |
206 0
Tola Moses Hector
Tola Moses Hector

Introduction

In Part 1 of this series, we built "CSwingEngine"—a reusable class that identifies significant swing points on H4, labels each one HH, LH, HL, or LL, and classifies market structure as an uptrend, downtrend, or range, regardless of what timeframe the calling EA or indicator is actually attached to. This article puts the engine to work on the first pattern in the series: the double top and double bottom. The detector uses the engine to confirm the H4 trend, then evaluates the last two confirmed H4 swing highs or lows for the structural requirements of a valid pattern. Entry triggers on the neckline break, timed on the EA's own chart timeframe. The measured move from the pattern height provides the take-profit target.

The double top is one of the most discussed reversal patterns in technical analysis. It appears when the price reaches the same resistance level twice, fails to break above it on both attempts, and then breaks below the trough that formed between the two peaks. The double bottom is the mirror: two tests of the same support level followed by a break above the intervening peak.

The pattern is simple to describe. The problem is that detectors built around the shape alone fire constantly—in uptrends, in downtrends, and in ranges—wherever two bars happen to reach approximately the same price. A well-designed double top detector does not start by looking for two equal highs. It starts by asking whether a prior uptrend exists. Only after confirming the trend does it check whether the last two swing highs form a valid double-top structure.

This ordering—context first, shape second—is the architecture this series applies to every pattern. In this article, the context check is "TREND_UP" for a double top and "TREND_DOWN" for a double bottom, and that answer always reflects the H4 structure, no matter which chart the EA is running on. If that check fails, the shape is never examined. The signal cannot fire outside its structural context.

There is a second ordering built into this design: the trend gate reads H4, but the neckline break—the actual entry trigger—is timed on the EA's own chart. A double top confirmed by the H4 structure can still be entered on an H1 or M15 close through the neckline, which is a materially faster and more precise entry than waiting for an H4 close would allow. Context comes from the higher timeframe. Timing comes from the timeframe you trade.

A third ordering governs the pattern's lifetime: detection happens once. Everything after that is state, not re-detection. A confirmed pattern is locked in, watched across as many bars as it takes to either break its neckline or expire, and never traded twice. This matters because the underlying H4 structure changes far less often than the chart timeframe. Without an explicit lifecycle, the same pattern could qualify and be entered more than once.

A note on timeframes before going further: every "H4" reference in this article describes the default value of "InpSwingTF." The swing timeframe is a configurable input, not a hardcoded assumption—if you set "InpSwingTF" to "PERIOD_D1" or "PERIOD_H1," every "H4" reference below should be read as "the swing timeframe you configured."

The central question this article is designed to answer is:

Has price made two nearly equal H4 tests of a significant level following a confirmed H4 trend, and has the neckline been broken with enough force—on the timeframe I actually trade—to confirm a reversal?

We will cover the following topics:

  1. The Double Top and Double Bottom—Theory and Measurement
  2. What Makes a Valid Pattern in This Implementation
  3. Architecture—Two Files, Three States
  4. Implementation in MQL5
  5. Known Limitations
  6. Conclusion


The Double Top and Double Bottom—Theory and Measurement

The double top has five structural components.

Double top—structure and measurement

Fig. 1. Double top—structure and measurement.

The prior trend is the H4 uptrend that precedes the pattern. Without it, the pattern has no context. "CSwingEngine" confirms this by requiring the most recently labeled H4 swing high to be HH and the most recently labeled H4 swing low to be HL before any double-top evaluation begins.

The first peak is the most recent H4 swing high in the uptrend—the high that price reached and failed to sustain. It is stored by the engine as the last confirmed swing high.

The trough is the H4 swing low between the two peaks. It defines the neckline. The neckline is the horizontal level the price must break below to confirm the reversal. The trough is stored by the engine as the swing low that occurred after the first peak.

The second peak is a subsequent H4 swing high that reaches approximately the same price level as the first peak. The tolerance for "approximately equal" is configurable—the default is 1.5 ATR, measured on H4 to match the timeframe the peaks themselves come from. If the second peak is within 1.5 H4-ATR of the first peak, the structure qualifies.

The neckline break is the trigger. Unlike the trend and the peaks, this check runs on the EA's own chart timeframe. When the price closes below the neckline on a confirmed bar of whatever timeframe the EA is attached to, the pattern is complete and the trade enters.

The measured move target is the height of the pattern—the distance from the neckline to the top of the peaks—projected downward from the neckline. If the peaks are at 1.1000 and the neckline is at 1.0850, the pattern height is 150 pips. The target is 1.0850 minus 150 pips, which equals 1.0700.

Double bottom—structure and measurement

Fig. 2. Double bottom—structure and measurement.

The double bottom mirrors all five components in the short direction. The prior H4 downtrend is confirmed by the engine. The two troughs are at approximately the same H4 price level. The neckline is the peak between the two troughs. The neckline break upward triggers entry on the chart's own timeframe. The target is the pattern height projected above the neckline.


What Makes a Valid Pattern in This Implementation?

The detector enforces six conditions before generating a signal. All six must be satisfied simultaneously.

  1. The trend condition, "CSwingEngine," must report "TREND_UP" for a double top or "TREND_DOWN" for a double bottom. This reads H4 structure regardless of the EA's chart timeframe. If the engine reports "TREND_RANGE," no evaluation proceeds.
  2. The swing count condition: the engine must have at least four confirmed H4 swing points in its array—two highs and two lows minimum. Fewer swings means insufficient structural history.
  3. The swing pairing condition: the detector must be able to identify two swings of the matching type (two highs for a double top, two lows for a double bottom) with exactly one swing of the opposite type between them. This is checked with an explicit boolean, not by testing whether a price field is still zero—a swing at price 0.0 is not realistic on any forex symbol, but treating an uninitialized price as the "not found yet" signal is still the wrong tool for the job, so the search below tracks whether the midpoint swing was actually found as its own flag.
  4. The point equality condition: the two points must be within "InpPeakTolerance" H4-ATR of each other. The default tolerance is 1.5 ATR. If they differ by more than this, they are not testing the same level and do not form a valid pattern.
  5. The pattern height condition: the neckline must sit below both points (double top) or above both points (double bottom) by at least "InpMinPatternHeight" H4-ATR. This ensures the pattern has meaningful height and prevents trivially flat structures from qualifying.
  6. The time-and-width condition: the first point must precede the neckline swing, which must precede the second point, and the number of H4 bars between the two points—measured from their timestamps, not from any array index—must fall between one and "InpMaxPatternBars." Measuring from timestamps rather than from an internal index difference means this check does not depend on any assumption about how the swing engine orders its array internally; it only depends on the "time" field, which is part of the engine's public contract.

Passing all six conditions makes a pattern eligible, not traded. Section 3 covers the state machine that decides what happens between eligibility and an actual position—including why eligibility alone is not enough to justify entry.

A note on ATR: this detector uses a single ATR handle computed on the H4 swing timeframe rather than the EA's chart timeframe. The peak tolerance and pattern height are both properties of the H4 structure, so measuring them against the H4 volatility is the correct scale. Using the chart timeframe's ATR here—M15 ATR, for instance—would apply a tolerance far too tight for the H4-sized peaks and would reject valid patterns. The ATR value is validated on every read. If the indicator is not warmed up or the symbol has a data gap, it can return zero or "EMPTY_VALUE". Because all tolerance checks multiply by ATR, an unchecked zero would silently disable the EA's distance filters.


Architecture—Two Files, Three States

  1. "DoubleTopEA.mq5" is the Expert Advisor. It owns all trade execution, position management, stop placement, lot sizing, and chart label drawing. It includes "SwingEngine.mqh" from Part 1 and uses the engine to get the current H4 trend and swing array. The pattern evaluation logic lives directly in the EA for clarity—in later articles in this series, pattern logic will be extracted into its own include file as the series grows.
  2. "SwingEngine.mqh" is unchanged from Part 1. Nothing in it needs to be modified to support double top detection. Its public interface provides everything the EA needs—including "GetPipValue" and "PipSize," the two infrastructure helpers every article in this series uses. Part 1's version of this article duplicated a local "PipSize()" inside the EA; that duplication is removed here. "DoubleTopEA.mq5" now calls the versions declared in "SwingEngine.mqh" directly, since the include is already required for the engine itself. One less function to keep in sync across files.

The EA operates on a new bar gate for its own chart timeframe, in addition to the engine's internal H4 gate. Pattern evaluation and neckline-break checks run once per completed chart bar. Swing detection and trend classification run once per completed H4 bar, regardless of how often the chart-bar gate fires. These two gates operate independently, and both are necessary: the chart-bar gate keeps entry timing precise, and the H4 gate keeps structure calculation cheap and stable.

This EA carries three explicit states.

  1. "DT_STATE_SCANNING" is the default state. Every completed chart bar, both "CheckDoubleTop()" and "CheckDoubleBottom()" run. If either returns a valid pattern whose second point has not already been traded, that pattern is locked into "g_active_pattern," and the EA moves to "DT_STATE_PATTERN_FORMED." A pattern is identified by the timestamp of its second point combined with its type—this pair is what "already traded" is checked against, so the same physical pattern cannot be entered twice, even across separate positions.
  2. "DT_STATE_PATTERN_FORMED" holds one locked pattern and performs three checks each bar. It revalidates the pattern against current swing data and drops the lock if it fails. It expires the pattern after "InpMaxBreakDelayBars" without a neckline break. Finally, it checks for a neckline break and attempts entry.
  3. "DT_STATE_IN_TRADE" is entered only after a real position opens. It exits back to "DT_STATE_SCANNING" only when "HasOpenPosition()"—a direct query of the terminal's open positions by symbol and magic number—reports no matching position. There is no separate boolean flag tracking "Am I in a trade?": the position list itself is the only source of truth, which means a terminal restart, a manually closed position, or an EA reattached to a chart with an existing position all resolve to the correct state on the very first tick, because "OnInit()" itself calls "HasOpenPosition()" to decide which state to start in.


Implementation in MQL5

The EA is built section by section.

Includes, Enumerations, and Input Parameters
//+------------------------------------------------------------------+
//|                                                  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" caps how long a locked pattern is allowed to wait for its neckline break before the setup is considered stale. "InpMaxSlipATR" stops the EA from chasing a neckline that has already been broken by a wide margin—a gap or a fast move can put the price well past the neckline before the EA's own bar close confirms it, and entering that far after the fact changes the trade's risk profile from what the pattern's stop and target were built around.

"InpDebugLog" controls a parallel logging path used throughout the detection functions: every condition that rejects a pattern can optionally print the reason, which matters when tuning parameters or investigating why the EA has gone quiet on a symbol that looks like it should be producing signals.

Pattern Result and Pattern State

The result structure carries an explicit pattern-type flag, so the caller never has to infer double top versus double bottom from context, and the EA's own state is modeled as an explicit three-value enumeration matching the three states described above.

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

The "point2_time" doubles as the pattern's identity. Two patterns of the same type with the same second-point timestamp are, by construction, the same pattern—the swing engine only ever produces one confirmed swing per H4 bar in a given direction, so this timestamp cannot collide between genuinely different patterns.

Global Variables and Indicator Handles

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

The "g_obj_prefix" is built in "OnInit()" from the symbol and magic number, so chart objects from two instances of this EA—or from this EA and an unrelated tool that happens to also use a "DT_" prefix—cannot collide or delete each other's drawings.

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

Chart Drawing Helpers

Objects are updated in place when they already exist rather than deleted and recreated every bar, and "ChartRedraw()" is called once per "OnTick()" pass at the end of the function rather than once per object.

//+------------------------------------------------------------------+
//| 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);
     }
  }
Finding Swings Without a Sentinel Value
//+------------------------------------------------------------------+
//| 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);
  }

Double Top Detection

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

Entry Execution

Both entry functions check "HasOpenPosition()" a second time immediately before sending an order, validate margin, normalize prices, account for both the broker's minimum stop distance and freeze distance, and reject entries where price has already run too far past the neckline to fill at a price consistent with the pattern's structure.
//+------------------------------------------------------------------+
//| 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 and OnDeinit
Input parameters are validated up front. The object-name prefix is built from the symbol and magic number. And the EA's starting state is decided by an actual position query rather than assumed to be "no position"—important for an EA reattached to a chart that already has an open trade from before a restart.
//+------------------------------------------------------------------+
//| 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—The Pattern State Machine

This is where the three states from Section 3 actually run. Every branch ends with a single "ChartRedraw()" call rather than one per drawing operation.

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

Because "DT_STATE_SCANNING" only locks onto one pattern at a time and moves straight to "DT_STATE_PATTERN_FORMED", there is never a moment where both a double top watch line and a double bottom watch line exist on the chart simultaneously—only one pattern is ever active, so only one "NECKLINE_WATCH" line is ever drawn.

What the Swing Engine Contributes

To appreciate what the engine adds to this detector, consider what happens when "GetTrend()" returns "TREND_RANGE." "CheckDoubleTop()" returns immediately on the first line. The six structural conditions are never evaluated. The swing array is never scanned. No labels are drawn.

Without this gate, the detector would frequently fire in sideways markets where two bars happen to reach approximately the same high. These are the false signals that make naive double top detectors unreliable. With the gate, the detector only evaluates the pattern structure when the market has already demonstrated directional intent through at least two consecutive higher highs and two consecutive higher lows. That prior structure is what gives the eventual reversal its significance.

What to Expect

Double top and double bottom patterns on H4 are not common. Expect between 5 and 15 qualifying signals per year on EURUSD. This is correct behavior—the six-condition filter is intentionally selective. The journal will log every pattern evaluation: when the engine reports "TREND_UP," the two peaks found, the neckline level, and whether the equality and height conditions passed or failed. This diagnostic output allows verification that the detector is finding genuine patterns rather than marginal ones.

If the journal shows frequent logging of "DT found" followed immediately by entries without a visible pattern on the chart, tighten "InpPeakTolerance" to 1.0 or reduce "InpMaxPatternBars" to 50. If the detector is finding no patterns at all, increase "InpSwingLookback" to 400 or reduce "InpSwingStrength" to 2.


DoubleTopEA demonstration

Fig. 3. Visual demonstration of the system.


Known Limitations

The pattern detector locks onto at most one double top or double bottom candidate per scan, chosen by whichever type is checked first—double top before double bottom when both are simultaneously eligible. It does not search every possible combination of swing points for the best-fitting pattern; it only considers the two most recent qualifying swings of each type. A wider search across multiple candidate pairings could occasionally find a more structurally significant pattern than the most recent one, at the cost of considerably more complexity.

The point equality tolerance ("InpPeakTolerance") is symmetric: a second point that is higher than the first by the tolerance amount passes just as easily as one that is lower by the same amount. Some double top traders prefer an asymmetric rule—the second peak should not meaningfully exceed the first, since a genuine higher high arguably contradicts the reversal thesis. This implementation does not distinguish between the two cases.

Position sizing is computed from the structural stop distance and does not separately model spread cost against the measured-move target. On tightly-spread majors at H1 and above, this is a small effect; on wider-spread symbols or shorter chart timeframes, actual realized reward-to-risk will run relatively below the structural figure the pattern implies.

"InpMaxPatternBars" and the width check inside "CheckDoubleTop()"/"CheckDoubleBottom()" are expressed in H4 bars (or whatever "InpSwingTF" is set to), not chart bars, because they are derived from swing timestamps on the swing timeframe. "InpMaxBreakDelayBars," by contrast, is expressed in chart bars because it times how long the EA is willing to wait on the timeframe it actually watches for the break. Mixing these two units up when tuning the EA will produce a limit far looser or far tighter than intended.

The neckline break condition requires a chart bar close through the neckline. On illiquid instruments or very short chart timeframes, intrabar moves through the neckline followed by a recovery are common; the close requirement filters most of these out, but a short chart timeframe, such as M5, will still see more of this noise than H1 will.

The EA tracks one position at a time by construction—"DT_STATE_IN_TRADE" blocks all scanning until that position closes. A second pattern that becomes eligible while a position is already open is never locked in, even if it would otherwise have qualified; it must requalify from scratch once scanning resumes.


Conclusion

The double top and double bottom are among the oldest and most discussed patterns in technical analysis. The reason they are discussed so widely is also the reason naive detectors fail: the patterns look simple, but their validity depends on the context in which they form, on the timeframe that context is read from, and on treating a confirmed pattern as a single event to be traded once rather than a condition to be re-checked and potentially re-entered on every bar.

This implementation enforces all three principles. The swing engine from Part 1 provides H4 market structure classification, independent of whatever chart the EA runs on. The six-condition evaluation provides the structural requirements, measured at the H4 scale. The three-state pattern lifecycle turns "a valid pattern exists" into "a position was opened for this specific pattern exactly once"—tracking a locked pattern across as many bars as it needs, discarding it if the underlying structure changes, expiring it if it waits too long, and refusing to trade the same second point twice. The neckline break itself is still timed with the precision of the chart's own timeframe.

All code was compiled and tested in MetaTrader 5. Save "SwingEngine.mqh" from Part 1 to "MQL5\Include\ChartPatterns\SwingEngine.mqh." Save "DoubleTopEA.mq5" to "MQL5\Experts\DoubleTopEA.mq5." Compile in MetaEditor with no additional dependencies. Recommended for EURUSD and GBPUSD on H4. Always test on a demo account before live deployment.

Attached files |
SwingEngine.mqh (17.09 KB)
DoubleTopEA.mq5 (28.43 KB)
Neural Networks in Trading: Generalizing Time Series Without Data-Specific Dependence (Conclusion) Neural Networks in Trading: Generalizing Time Series Without Data-Specific Dependence (Conclusion)
The article will show you how Mamba4Cast turns theory into a working trading algorithm and lays the groundwork for your own experiments. Do not miss this opportunity to gain a full range of knowledge and inspiration for developing your own strategy.
Making Custom Indicators for Beginners (Part 2): Fisher-style Indicator Making Custom Indicators for Beginners (Part 2): Fisher-style Indicator
This article develops a Fisher‑style Indicator in MQL5 from first principles: normalize price within a recent high/low window, smooth and clamp the value, then apply a logarithmic transform. We cover buffer wiring, calculation‑buffer state management across bars, and seeding for stable starts. An accompanying EA implements threshold and reversal confirmation to show how to act on the signal.
Features of Experts Advisors Features of Experts Advisors
Creation of expert advisors in the MetaTrader trading system has a number of features.
Master MQL5 — From Beginner to Pro (Part VII): Principles of Debugging MQL Applications Master MQL5 — From Beginner to Pro (Part VII): Principles of Debugging MQL Applications
Debugging is an integral part of the programming cycle. This article discusses common techniques for debugging any application running in the MetaTrader 5 environment.