Automating Chart Patterns in MQL5 (Part 2): The Double Top and Double Bottom
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:
- The Double Top and Double Bottom—Theory and Measurement
- What Makes a Valid Pattern in This Implementation
- Architecture—Two Files, Three States
- Implementation in MQL5
- Known Limitations
- Conclusion
The Double Top and Double Bottom—Theory and Measurement
The double top has five structural components.

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.

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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
- "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.
- "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.
- "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.
- "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.
- "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.

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.
Warning: All rights to these materials are reserved by MetaQuotes Ltd. Copying or reprinting of these materials in whole or in part is prohibited.
This article was written by a user of the site and reflects their personal views. MetaQuotes Ltd is not responsible for the accuracy of the information presented, nor for any consequences resulting from the use of the solutions, strategies or recommendations described.
Neural Networks in Trading: Generalizing Time Series Without Data-Specific Dependence (Conclusion)
Making Custom Indicators for Beginners (Part 2): Fisher-style Indicator
Features of Experts Advisors
Master MQL5 — From Beginner to Pro (Part VII): Principles of Debugging MQL Applications
- Free trading apps
- Over 8,000 signals for copying
- Economic news for exploring financial markets
You agree to website policy and terms of use