Chapter Two: Visual Strategy Building with TradingView
I set out to develop a TradingView script that could automatically detect double top and double bottom patterns while incorporating filters to refine the signals. Over the course of about four hours, I iteratively developed and debugged the script—tweaking parameters, adjusting logic, and using TradingView’s visual feedback to better understand how each component behaved.
Working with Copilot to draft and refine the script proved to be a practical approach. The ability to see the pivots, neckline formations, and filter effects directly on the charts helped me quickly identify and correct any issues. This process, from the initial idea through iterative improvements to the final version, highlights a measured and systematic approach to developing a trading tool that is both visually intuitive and logically robust. The results are shown in Figure 1.
Figure 1. A double bottom is formed, the neck line is broken, and a long trade is opened. The trade is closed when the candle's low drops below the Donchian low. The volume filter was disabled.
Explanation of the logic
Below is a detailed description of how the logic works. Only the Long side is given as the short side is exactly the opposite.
Overall Workflow
Detection: The strategy monitors the HMA for three-bar pivot formations. Once a first pivot is identified, it waits for a second matching pivot within a specified range of bars and with a similar HMA value (filtered using the ATR-based tolerance).
Neckline Setting: When a pair of matching pivots is confirmed, the neckline is calculated as either the highest high (for a double bottom) or the lowest low (for a double top) between the two pivot points.
Trade Entry: A trade is triggered if the price breaks through the neckline (plus an ATR buffer) during a valid time window—no earlier than the minimum waiting period and no later than the expiry period. Additionally, the volume filter (if enabled) must confirm that market participation is sufficient, and trade toggles decide if a long or short position is allowed.
Trade Exit: Open positions are monitored using a lagged Donchian channel, and exits occur when price action breaches these dynamic support or resistance levels.
Pattern Detection Using the Hull Moving Average
Pivot Low (for a potential double bottom): A pivot low is identified when the middle bar (indexed as hma[1]) is lower than its neighbors:
isPivotLow if hma[0] > hma[1] and hma[1] < hma[2]
Double Bottom (Bullish) Pattern:
First Pivot: When the first pivot low is found, its value and bar index (let’s denote these as PL1 and BL1) are stored.
Second Pivot: A later pivot low (with value PL2 appearing at bar BL2) is considered only if the number of bars between these pivots meets the following condition:
minBarsBetween ≤ (BL2−BL1) ≤ pivotCompareLookback
ATR-based Tolerance Check: To ensure the similarity of the two pivots, the difference between their HMA values must be less than or equal to:
∣PL2−PL1∣ ≤ (atrTolerance × ATR)
Neckline setting
Determining the Neckline for Long (Bullish) Entry: Between the first and second pivot, the strategy tracks the highest high. This running maximum (denoted as Hmax) is determined for all bars between BL1 and BL2. The neckline is then set as:
necklineLong=Hmax
Trade Entry Conditions
A. Breakout Entry
For a long trade (double bottom), the entry is made if the following condition is met:
close > necklineLong + (necklineBuffer × ATR)
where the necklineBuffer is a multiplication factor to scale the ATR. These conditions ensure that the price has not only moved past the raw neckline but has done so with a cushion proportional to market volatility.
B. Time Window for Valid Entries
To ensure that trades occur in a timely manner, the strategy only accepts entries if they occur within a dedicated window after the neckline is established. Two parameters control this:
Minimum Waiting Period (minWaitBars): The strategy does not allow any trade entry before:
bar index ≥ (necklineBar + minWaitBars)
This delay prevents premature entries that might still be part of the pattern’s formation.
Neckline Expiry Period: Once the neckline is set, the opportunity is considered valid only until:
bar index ≤ (necklineBar + expiryBars)
If the price fails to break out within this period, the pattern is regarded as expired, and no trade is initiated on that signal.
C. Volume Filter
To avoid taking trades on weak or unsupported moves, a volume filter condition is optionally applied. This filter works as follows:
A moving average of volume is calculated over a period defined by volumeLength\text{volumeLength}:
volSMA = SMA(volume , volumeLength)
The current bar’s volume must exceed this moving average scaled by a multiplier volMultiplier\text{volMultiplier}:
volume > volMultiplier × volSMA
This requirement confirms that any breakout signal is supported by above-average trading volume, thus increasing the reliability of the signal.
Trade Exit Conditions
Once a trade is entered, the strategy uses a lagging Donchian channel to determine exit points. The Donchian channel is calculated using the previous bar’s data over a specified lookback period (donchianLength). The exit is triggered if the price’s low falls below the Donchian low:
donchianLow=min{low[1],low[2],…,low[donchianLength]}
if
low < donchianLow
then the long trade is closed.
Next Steps and Outlook
This TradingView version is an important step in developing the strategy’s main logic and clear visuals. Using Pine Script helped recognize patterns and made strategy review easier. But this is only the start. In the next chapter, I’ll be transferring this pattern detection framework into MetaTrader 5, refining it into a more actionable Expert Advisor. The first step is to development of the custom Hull moving average indicator.
Strategy Code
Below is the full TradingView script I developed for detecting double top and double bottom patterns with ATR-based and volume filtering. It’s optimized for clarity and modular editing, so feel free to customize parameters or extend it further. I’ll be referencing this structure as the foundation when porting the strategy to MetaTrader 5 in the next post.
-
//@version=5
strategy("Double Top/Bottom (Visual + Lookback Logic) - Early Entry + Expiry", overlay=true, default_qty_type=strategy.percent_of_equity, default_qty_value=10)
// === INPUTS ===
hmaLength = input.int(21, "HMA Length")
atrLength = input.int(14, "ATR Length")
donchianLength = input.int(20, "Donchian Channel Length")
atrTolerance = input.float(1.0, "ATR Tolerance Multiplier", step=0.1)
necklineBuffer = input.float(0.5, "Neckline Break Buffer (in ATR)", step=0.1)
pivotCompareLookback = input.int(30, "Lookback Period for Pivot Comparison")
minBarsBetween = input.int(10, "Minimum Bars Between Matching Pivots")
expiryBars = input.int(10, "Bars Until Neckline Expiry")
// === NEW TRADE CONTROLS ===
minWaitBars = input.int(0, "Minimum Waiting Period in Bars", minval=0)
enableLongTrades = input.bool(true, "Enable Long Trades")
enableShortTrades = input.bool(true, "Enable Short Trades")
// === ADDITIONAL OPTIONS ===
// Enter on neckline break (true) or immediately upon pattern formation (false)
entryByBreak = input.bool(true, "Enter on Neckline Break (if false, enter on pattern formation)")
// === VOLUME FILTER INPUTS ===
useVolumeFilter = input.bool(true, "Use Volume Filter")
volumeLength = input.int(20, "Volume MA Lookback Period")
volMultiplier = input.float(1.0, "Volume Multiplier", step=0.1)
// === INDICATORS ===
hma = ta.hma(close, hmaLength)
atr = ta.atr(atrLength)
plot(hma, color=color.orange, title="Hull MA")
// Calculate the moving average of volume and determine if current volume is above the threshold.
volSMA = ta.sma(volume, volumeLength)
volCondition = volume > (volSMA * volMultiplier)
// === SIMPLE 3-BAR PIVOT DETECTION ===
isPivotHigh = hma[0] < hma[1] and hma[1] > hma[2]
isPivotLow = hma[0] > hma[1] and hma[1] < hma[2]
// === VARIABLES FOR NECKLINE DETERMINATION (EXPLICIT PIVOT STORAGE) ===
// For double bottoms (bullish setup):
var float necklineLong = na // final neckline level for long entries
var int necklineLongBar = na // bar index when the neckline was established
var float firstBottomPivot = na // the first pivot low value
var int firstBottomBar = na // the bar index where the first pivot low occurred
var float maxSinceFirstBottom = na // running highest high since the first pivot low
// For double tops (bearish setup):
var float necklineShort = na // final neckline level for short entries
var int necklineShortBar = na // bar index when the neckline was established
var float firstTopPivot = na // the first pivot high value
var int firstTopBar = na // the bar index where the first pivot high occurred
var float minSinceFirstTop = na // running lowest low since the first pivot high
// === DOUBLE BOTTOM (LONG) DETECTION WITH EXPLICIT PIVOT STORAGE ===
if isPivotLow
// A pivot low candidate is identified on hma[1] at bar_index-1.
if na(firstBottomPivot)
// If no pivot is stored, save this one.
firstBottomPivot := hma[1]
firstBottomBar := bar_index - 1
maxSinceFirstBottom := high
else
// Compute spacing from the first stored pivot.
spacing = (bar_index - 1) - firstBottomBar
if spacing >= minBarsBetween and spacing <= pivotCompareLookback
// Check if the two pivots are close enough (within ATR-based tolerance).
if math.abs(hma[1] - firstBottomPivot) <= atrTolerance * atr
// Valid double bottom detected.
// Determine the neckline strictly between the two pivots.
necklineLong := maxSinceFirstBottom
necklineLongBar := bar_index - 1
line.new(firstBottomBar, firstBottomPivot, bar_index - 1, hma[1], color=color.green, width=1)
line.new(bar_index - 1, necklineLong, bar_index + 5, necklineLong, color=color.lime, style=line.style_dashed)
label.new(bar_index - 1, hma[1], "Double Bottom", style=label.style_label_up, color=color.green)
// Clear stored pivot values.
firstBottomPivot := na
firstBottomBar := na
maxSinceFirstBottom := na
else
// If the tolerance condition fails, treat the current pivot as the new starting point.
firstBottomPivot := hma[1]
firstBottomBar := bar_index - 1
maxSinceFirstBottom := high
else if spacing > pivotCompareLookback
// If too many bars have elapsed, reset with the current pivot.
firstBottomPivot := hma[1]
firstBottomBar := bar_index - 1
maxSinceFirstBottom := high
// Update the running maximum high between the first bottom and now.
if not na(firstBottomPivot)
maxSinceFirstBottom := math.max(maxSinceFirstBottom, high)
// === DOUBLE TOP (SHORT) DETECTION WITH EXPLICIT PIVOT STORAGE ===
if isPivotHigh
if na(firstTopPivot)
firstTopPivot := hma[1]
firstTopBar := bar_index - 1
minSinceFirstTop := low
else
spacing = (bar_index - 1) - firstTopBar
if spacing >= minBarsBetween and spacing <= pivotCompareLookback
if math.abs(hma[1] - firstTopPivot) <= atrTolerance * atr
// Valid double top detected.
// Determine the neckline strictly between the two pivots.
necklineShort := minSinceFirstTop
necklineShortBar := bar_index - 1
line.new(firstTopBar, firstTopPivot, bar_index - 1, hma[1], color=color.red, width=1)
line.new(bar_index - 1, necklineShort, bar_index + 5, necklineShort, color=color.red, style=line.style_dashed)
label.new(bar_index - 1, hma[1], "Double Top", style=label.style_label_down, color=color.red)
// Clear stored pivot values.
firstTopPivot := na
firstTopBar := na
minSinceFirstTop := na
else
firstTopPivot := hma[1]
firstTopBar := bar_index - 1
minSinceFirstTop := low
else if spacing > pivotCompareLookback
firstTopPivot := hma[1]
firstTopBar := bar_index - 1
minSinceFirstTop := low
// Update the running minimum low between the first top and now.
if not na(firstTopPivot)
minSinceFirstTop := math.min(minSinceFirstTop, low)
// === ENTRY CONDITIONS ===
var bool longEntry = false
var bool shortEntry = false
if entryByBreak
// For a breakout entry:
// • The price must breach the neckline (with an added buffer),
// • The breach must occur no sooner than `minWaitBars` after the neckline is set,
// • And it must occur before the neckline expires (i.e. within `expiryBars`).
longEntry := not na(necklineLong) and close > necklineLong + (necklineBuffer * atr) and
(bar_index >= necklineLongBar + minWaitBars) and (bar_index <= necklineLongBar + expiryBars)
shortEntry := not na(necklineShort) and close < necklineShort - (necklineBuffer * atr) and
(bar_index >= necklineShortBar + minWaitBars) and (bar_index <= necklineShortBar + expiryBars)
else
// For immediate entry upon pattern formation (without waiting for a breakout),
// the trade is allowed only if the formation is within the waiting period window.
longEntry := not na(necklineLong) and (bar_index >= necklineLongBar + minWaitBars) and (bar_index <= necklineLongBar + expiryBars)
shortEntry := not na(necklineShort) and (bar_index >= necklineShortBar + minWaitBars) and (bar_index <= necklineShortBar + expiryBars)
// === TRADE EXECUTION WITH TOGGLES ===
if longEntry and enableLongTrades and (not useVolumeFilter or volCondition)
strategy.entry("Long", strategy.long)
// Reset the long neckline variables after entry.
necklineLong := na
necklineLongBar := na
if shortEntry and enableShortTrades and (not useVolumeFilter or volCondition)
strategy.entry("Short", strategy.short)
// Reset the short neckline variables after entry.
necklineShort := na
necklineShortBar := na
// === EXIT CONDITIONS (Based On Previous Bar’s Donchian Channel) ===
donchianLow = ta.lowest(low[1], donchianLength)
donchianHigh = ta.highest(high[1], donchianLength)
if strategy.position_size > 0 and low < donchianLow
strategy.close("Long")
if strategy.position_size < 0 and high > donchianHigh
strategy.close("Short")
// === DEBUGGING PLOTS FOR DONCHIAN CHANNEL ===
plot(donchianLow, color=color.blue, title="Donchian Low (prev bar)")
plot(donchianHigh, color=color.purple, title="Donchian High (prev bar)")