A step-by-step walkthrough of turning a simple candlestick pattern into a structured, risk-controlled Pine Script strategy.
A bullish engulfing pattern is simple to code. Building a reliable and profitable strategy around it is much harder. This guide walks through the full process: from detecting the pattern, to adding trend context, ATR-based exits, and fixed-risk position sizing — with every decision explained.
A bullish engulfing pattern is simple to code. Building a reliable and profitable strategy around it is much harder.
That distinction matters. I see many TradingView strategies that look clean on the chart but fall apart when you examine their implementation more closely. The candle pattern is fine, but the exits are defined in the wrong units. The stop distance changes, but the position size remains the same. The backtest appears neat, yet the actual cash risk per trade is inconsistent.
That is the point at which strategy design stops being about candlesticks and starts becoming about engineering.
For this build, I used a Bullish Engulfing setup on the 2-hour timeframe. I think that is a good fit for index-style markets because the higher timeframe tends to smooth out some of the noise you see on lower charts. Lower timeframes can be useful, especially for intraday work, but they are also more sensitive to short-term volatility, spread effects, and random price movement. On a 2-hour chart, the signals are less frequent, but they are often cleaner and easier to evaluate in a structured way.
That does not mean higher timeframes are automatically profitable, or that lower timeframes should be avoided. It simply means that for a pattern like a bullish engulfing, I often prefer to start on a higher chart where the price structure is less chaotic and a trend filter can do a better job of separating genuine pullbacks from random noise.
If you want the pure candlestick background first, the Engulfing Candlestick Pattern article is the natural companion to this one. Here, I want to focus on the strategy-building process: how I take a simple chart idea and turn it into a backtest that behaves more realistically.
Before I build a full-blown strategy, I have an idea or concept around which to build it. That is typically an idea the industry considers valuable in the market I'm trading. Examples include mean reversion, opening-range breakouts, price action, etc. I want the idea to be clean and explicit. If it's not easy to explain, then the inherent complexity could make it difficult to build additional concepts on top of it.
For this worked example of a bullish engulfing pattern, I define it as:
It is possible to be far more restrictive with the constraints around how we define a bullish engulfing pattern, but for this example, it's a good starting point.
That gives me a detector that I've coded up as a TradingView indicator:
//@version=6
indicator("Bullish Engulfing Detector", overlay=true)
// Previous candle bearish
prevBearish = close[1] < open[1]
// Current candle bullish
currBullish = close > open
// Current candle body engulfs previous candle body
bodyEngulfs = open <= close[1] and close >= open[1]
// Confirmed bullish engulfing pattern
bullishEngulfing = prevBearish and currBullish and bodyEngulfs and barstate.isconfirmed
plotshape(
bullishEngulfing,
title="Bullish Engulfing",
style=shape.triangleup,
location=location.belowbar,
size=size.small
)
At this point, I am not trying to make money with it. I am only trying to make sure the logic is correct. That separation is important. If the signal itself is wrong, everything I build on top of it will be wrong too.
It also ties directly into the same principle I covered in the 7 Rules for Backtesting in TradingView guide: get the structural basics right first.
Once I am happy that the pattern is being detected properly, the next step is to turn it into a strategy.
That does not mean it is suddenly tradable. It just means the script now issues orders rather than only marking candles on the chart.
A minimal long-only version looks like this:
//@version=6
strategy("Bullish Engulfing Strategy", overlay=true, process_orders_on_close=true)
// Previous candle bearish
prevBearish = close[1] < open[1]
// Current candle bullish
currBullish = close > open
// Current candle body engulfs previous candle body
bodyEngulfs = open <= close[1] and close >= open[1]
// Confirmed bullish engulfing pattern
bullishEngulfing = prevBearish and currBullish and bodyEngulfs and barstate.isconfirmed
// Long entry condition
longCondition = bullishEngulfing and strategy.position_size == 0
if longCondition
strategy.entry("Long", strategy.long)
plotshape(
bullishEngulfing,
title="Bullish Engulfing",
style=shape.triangleup,
location=location.belowbar,
size=size.small
)
That is still only the skeleton of a strategy, but it is an important step. I want to see whether the entries appear where I expect them to appear before I add filters, exits, and position sizing.
A bullish engulfing candle can appear almost anywhere. Some of those setups form during a healthy pullback in an uptrend. Others are simply temporary bounces inside a larger down move.
One of the simplest ways I improve that context is by using an EMA as a trend filter. I've already covered this idea more generally in the Trend Filter concept article, and the EMA (Exponential Moving Average) article provides the indicator-specific background.
Here, the logic is simple: I only want long signals when the price is above the EMA.
//@version=6
strategy("Bullish Engulfing EMA Strategy", overlay=true, process_orders_on_close=true)
// EMA input
emaLength = input.int(50, title="EMA Length", minval=1)
emaValue = ta.ema(close, emaLength)
// Bullish engulfing logic
prevBearish = close[1] < open[1]
currBullish = close > open
bodyEngulfs = open <= close[1] and close >= open[1]
bullishEngulfing = prevBearish and currBullish and bodyEngulfs and barstate.isconfirmed
// Trend filter
aboveEma = close > emaValue
// Long entry condition
longCondition = bullishEngulfing and aboveEma and strategy.position_size == 0
if longCondition
strategy.entry("Long", strategy.long)
plot(emaValue, title="EMA", linewidth=2)
plotshape(
bullishEngulfing,
title="Bullish Engulfing",
style=shape.triangleup,
location=location.belowbar,
size=size.small
)
That is the point where the script starts behaving less like a pattern detector and more like a strategy framework. I am no longer saying "buy every bullish engulfing candle." I am saying "buy bullish engulfing candles that appear in a long-biased environment."
Once the entry logic is in place, exits become the next major design decision.
A fixed 20-point stop and 40-point target can work in some conditions, but it is a blunt tool. On some charts, it will be too tight. On others, it will be too wide. That is why I prefer to use ATR as the basis for the stop loss and take profit.
The ATR (Average True Range) article covers the indicator itself, and the ATR Position Sizing article goes deeper into sizing, but the concept is straightforward:
That makes the exit distances adaptive rather than static.
//@version=6
strategy("Bullish Engulfing EMA ATR Strategy", overlay=true, process_orders_on_close=true)
// Inputs
emaLength = input.int(50, title="EMA Length", minval=1)
atrLength = input.int(14, title="ATR Length", minval=1)
slFactor = input.float(1.5, title="Stop Loss ATR Factor", minval=0.1, step=0.1)
tpFactor = input.float(2.0, title="Take Profit ATR Factor", minval=0.1, step=0.1)
// Core calculations
emaValue = ta.ema(close, emaLength)
atrValue = ta.atr(atrLength)
// Bullish engulfing logic
prevBearish = close[1] < open[1]
currBullish = close > open
bodyEngulfs = open <= close[1] and close >= open[1]
bullishEngulfing = prevBearish and currBullish and bodyEngulfs and barstate.isconfirmed
// Long filter
longCondition = bullishEngulfing and close > emaValue and strategy.position_size == 0
if longCondition
definedSL = atrValue * slFactor
definedTP = atrValue * tpFactor
stopTicks = math.round(definedSL / syminfo.mintick)
targetTicks = math.round(definedTP / syminfo.mintick)
strategy.entry("Long", strategy.long)
strategy.exit("Long Exit", from_entry="Long", profit=targetTicks, loss=stopTicks)
plot(emaValue, title="EMA", linewidth=2)
The important detail here is unit handling. ATR is a price distance, while strategy.exit() with profit= and loss= expects distances in ticks. That is why the ATR distances are converted using syminfo.mintick. If the units are wrong, the strategy may still compile and run, but it is not doing what the author thinks it is doing.
That sounds small, but it is one of the easiest ways to distort a backtest. The Strategy Order Types Explained guide covers this in more detail.
At this stage, the strategy has a signal, a filter, and ATR-based exits. It still has a serious flaw.
The stop distance changes from one trade to the next, but the position size is still fixed.
That means the actual cash risk varies. A wider ATR stop risks more money. A tighter ATR stop risks less. So even though the chart looks tidy, the backtest is not comparing like with like.
That is where fixed-risk sizing becomes essential.
The logic is simple:
position size = target cash risk ÷ stop distance
If the stop gets wider, the position size gets smaller. If the stop gets tighter, the position size gets larger. That is how I keep the risk per trade much more consistent.
For this final version, I used the following settings on the 2-hour NAS100USD chart:
Here is the updated strategy:
//@version=6
strategy("Bullish Engulfing EMA ATR Fixed Risk Strategy", overlay=true, process_orders_on_close=true)
// Inputs
emaLength = input.int(20, title="EMA Length", minval=1)
atrLength = input.int(14, title="ATR Length", minval=1)
slFactor = input.float(0.5, title="Stop Loss ATR Factor", minval=0.1, step=0.1)
tpFactor = input.float(2.5, title="Take Profit ATR Factor", minval=0.1, step=0.1)
cashRiskPerTrade = input.float(100.0, title="Cash Risk Per Trade", minval=1)
qtyStep = input.float(0.01, title="Quantity Step", minval=0.01, step=0.01)
// Core calculations
emaValue = ta.ema(close, emaLength)
atrValue = ta.atr(atrLength)
// Bullish engulfing logic
prevBearish = close[1] < open[1]
currBullish = close > open
bodyEngulfs = open <= close[1] and close >= open[1]
bullishEngulfing = prevBearish and currBullish and bodyEngulfs and barstate.isconfirmed
// Trend filter
longCondition = bullishEngulfing and close > emaValue and strategy.position_size == 0
if longCondition
definedSL = atrValue * slFactor
definedTP = atrValue * tpFactor
posSizeRaw = cashRiskPerTrade / definedSL
posSize = math.floor(posSizeRaw / qtyStep) * qtyStep
stopTicks = math.round(definedSL / syminfo.mintick)
targetTicks = math.round(definedTP / syminfo.mintick)
if posSize > 0
strategy.entry("Long", strategy.long, qty=posSize)
strategy.exit("Long Exit", from_entry="Long", profit=targetTicks, loss=stopTicks)
// Plotting
plot(emaValue, title="EMA", linewidth=2)
plotshape(
bullishEngulfing,
title="Bullish Engulfing",
style=shape.triangleup,
location=location.belowbar,
size=size.small
)
The qtyStep input is not there to optimise performance. It is there to keep the strategy realistic.
The position-size calculation may produce values like 1.73, 0.84, or 2.11. Some brokers or instruments allow that. Others do not. qtyStep tells the strategy the smallest size increment it is allowed to use, and the script rounds down to the nearest valid amount.
So if the raw size is 1.734:
That matters because it affects how closely the actual trade risk equals the intended risk.
This is really a market mechanics setting, not a strategy-tuning parameter. It ties directly into the ideas covered in the Risk Management & Position Sizing guide and the Risk Reward concept page.
Because this version is designed around a candlestick pattern plus a trend filter, I think the 2-hour chart is a sensible home for it.
One of the problems with lower timeframes is that they are more vulnerable to short-term volatility, random spikes, microstructure noise, and spread distortion. That does not make them unusable, but it does mean the signal quality can degrade quickly, especially for reversal-style entries.
On a higher timeframe such as 2 hours, I usually get:
That does not mean the 2-hour timeframe is "better" in all cases. It means it is often more reliable for this kind of structured pattern strategy, especially when the goal is to build something robust rather than force a high trade count.
This also links well with the Higher Timeframe Confirmation article, because the general principle is the same: as you move higher, the structure often becomes easier to interpret and less reactive to short-lived market noise.
Using the settings above on the 2-hour chart, the version I ended up with produced the following top-level results in TradingView's Strategy Tester:
I would treat those numbers as a useful research result rather than a final verdict. They tell me the structure has merit, but they do not prove the strategy is universally strong. I would still want to inspect the trade distribution, test across market regimes, and challenge the assumptions around execution, session coverage, and slippage.
Still, this is exactly the kind of progression I look for. I start with a simple pattern, add context, improve the exits, fix the position sizing, and then check whether the resulting behaviour is stable enough to justify further work.
Here is the full cleaned-up version as a complete script:
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
// Copyright (c) 2026 - BuildTradingStrategies.com / TradeXecution.com
//@version=6
strategy("Bullish Engulfing EMA ATR Fixed Risk Strategy", overlay=true, process_orders_on_close=true)
// Inputs
emaLength = input.int(20, title="EMA Length", minval=1)
atrLength = input.int(14, title="ATR Length", minval=1)
slFactor = input.float(0.5, title="Stop Loss ATR Factor", minval=0.1, step=0.1)
tpFactor = input.float(2.5, title="Take Profit ATR Factor", minval=0.1, step=0.1)
cashRiskPerTrade = input.float(100.0, title="Cash Risk Per Trade", minval=1)
qtyStep = input.float(0.01, title="Quantity Step", minval=0.01, step=0.01)
// Core calculations
emaValue = ta.ema(close, emaLength)
atrValue = ta.atr(atrLength)
// Bullish engulfing logic
prevBearish = close[1] < open[1]
currBullish = close > open
bodyEngulfs = open <= close[1] and close >= open[1]
bullishEngulfing = prevBearish and currBullish and bodyEngulfs and barstate.isconfirmed
// Trend filter
longCondition = bullishEngulfing and close > emaValue and strategy.position_size == 0
if longCondition
definedSL = atrValue * slFactor
definedTP = atrValue * tpFactor
posSizeRaw = cashRiskPerTrade / definedSL
posSize = math.floor(posSizeRaw / qtyStep) * qtyStep
stopTicks = math.round(definedSL / syminfo.mintick)
targetTicks = math.round(definedTP / syminfo.mintick)
if posSize > 0
strategy.entry("Long", strategy.long, qty=posSize)
strategy.exit("Long Exit", from_entry="Long", profit=targetTicks, loss=stopTicks)
// Plotting
plot(emaValue, title="EMA", linewidth=2)
plotshape(
bullishEngulfing,
title="Bullish Engulfing",
style=shape.triangleup,
location=location.belowbar,
size=size.small
)
The most important lesson in this build is not that bullish engulfing is a magic pattern. It is not. Sometimes it will work well, sometimes it will not, and that will depend on the instrument, timeframe, trend context, volatility regime, and how the exits are structured.
The more important lesson is that strategy quality is usually determined less by the entry pattern than by the implementation wrapped around it.
A simple candlestick pattern can become far more useful when I combine it with trend context, volatility-based exits, and fixed-risk sizing. On the other hand, a visually pleasing setup can become almost useless if the stop units are wrong, the quantity logic is unrealistic, or the backtest quietly allows variable risk from one trade to the next.
That is exactly why I like using a bullish engulfing framework as a teaching example. It is simple enough to understand quickly, but rich enough to expose what really matters: structure, execution, and risk discipline.
A candlestick pattern is not magic — it is a visual summary of the balance between buyers and sellers over a time period. Understanding why a pattern works makes you a better judge of when it will and will not.
A bullish engulfing pattern tells a specific story in order flow terms:
That third step is the signal. It means that buyers did not simply resist — they overwhelmed the sellers enough to erase the entire prior move and extend beyond it. In liquid, exchange-traded instruments, this kind of momentum shift is often associated with genuine institutional order flow: large buyers stepping in at a level they consider significant.
The pattern becomes stronger when it occurs:
It becomes weaker or unreliable when:
Understanding these failure modes is as valuable as understanding the pattern itself. The setup alone does not create edge — the setup in context does.
The bullish engulfing pattern is a price-based signal. Volume provides the independent confirmation that price alone cannot.
A bullish engulfing candle on below-average volume suggests that the move was driven by relatively few participants — possibly a short-covering rally, a thin-market drift, or simply a lack of sellers rather than genuine buyer conviction. These can still be profitable but are statistically less reliable.
A bullish engulfing candle on above-average volume confirms that real market participants were actively buying. Institutional order flow requires size, and size shows up in volume.
In Pine Script, a simple volume filter looks like this:
``` volumeMA = ta.sma(volume, 20) strongVolume = volume > volumeMA * 1.2 bullishEngulfingConfirmed = bullishEngulfing and strongVolume ```
This requires the current bar's volume to be at least 20% above its 20-bar average. The exact threshold is a parameter to test, not a universal truth. The principle — that volume should confirm, not contradict — is the important point.
The bullish engulfing pattern's performance varies significantly across timeframes. As a general principle:
My experience working with this pattern on the 2H timeframe for NAS100 reflects this balance — enough signal frequency to be useful, with enough timeframe weight to carry some structural meaning. Testing the same logic on the 5-minute chart produces very different results, and the degradation is not random — it reflects the genuine reduction in signal quality.
The bearish engulfing pattern is the symmetrical short-side counterpart: a bullish candle followed by a bearish candle whose body fully engulfs the prior. All the same principles apply — trend context, volume confirmation, structural location, and timeframe weight. If you build the long-side version, the short-side version follows naturally from the same framework. Whether it performs as well will depend on the instrument and its directional bias.