Candlestick engulfing patterns attempt to capture short-term reversals. In isolation they trigger frequently, especially on crypto. A common way to reduce low-quality signals is to require confirmation that price is “stretched” using an oscillator such as RSI, then manage risk with a mechanical exit like a trailing stop.
This article walks through a Backtrader implementation of:
Bullish/Bearish Engulfing detection
RSI confirmation filter
Trailing stop-loss exits
A simple ETH-USD 1h backtest workflow
A bullish entry is triggered when all of the following are true:
Prior candle is bearish: \(C_{t-1} < O_{t-1}\)
Current candle is bullish: \(C_t > O_t\)
Current candle’s real body engulfs the prior body:
\[
O_t < C_{t-1} \quad \text{and} \quad C_t > O_{t-1}
\]
RSI indicates oversold: \(RSI_t < 30\)
A bearish entry is triggered when:
Prior candle is bullish: \(C_{t-1} > O_{t-1}\)
Current candle is bearish: \(C_t < O_t\)
Body engulfing:
\[
O_t > C_{t-1} \quad \text{and} \quad C_t < O_{t-1}
\]
RSI indicates overbought: \(RSI_t > 70\)
After entering a position, a trailing stop is used so that the stop
price follows favorable movement at a fixed distance. With
trailpercent=0.01, the trailing stop distance is roughly
\(1\%\) of price.
Conceptually, for a long position:
The trailing stop moves up when price rises.
It does not move down when price falls.
we exit when price crosses the trailing stop.
Below is a clean, standalone version of the strategy. It does three things:
computes RSI
checks engulfing conditions + RSI filters
places a market entry, then attaches a trailing stop exit
import backtrader as bt
class EngulfingPatternStrategy(bt.Strategy):
params = (
('rsi_period', 24),
('rsi_oversold', 30),
('rsi_overbought', 70),
('trail_percent', 0.01),
)
def __init__(self):
self.order = None
self.rsi = bt.indicators.RSI(self.data.close, period=self.p.rsi_period)
def notify_order(self, order):
if order.status in (order.Submitted, order.Accepted):
return
if order.status == order.Completed:
# Attach a trailing stop in the opposite direction after entry fills
if order.isbuy():
self.sell(exectype=bt.Order.StopTrail,
trailpercent=self.p.trail_percent)
elif order.issell():
self.buy(exectype=bt.Order.StopTrail,
trailpercent=self.p.trail_percent)
self.order = None
def next(self):
# Skip if waiting for an order fill or already in a position
if self.order or self.position:
return
# Bullish Engulfing
prev_bear = self.data.close[-1] < self.data.open[-1]
curr_bull = self.data.close[0] > self.data.open[0]
bull_engulf = (self.data.open[0] < self.data.close[-1] and
self.data.close[0] > self.data.open[-1])
oversold = self.rsi[0] < self.p.rsi_oversold
if prev_bear and curr_bull and bull_engulf and oversold:
self.order = self.buy()
return
# Bearish Engulfing
prev_bull = self.data.close[-1] > self.data.open[-1]
curr_bear = self.data.close[0] < self.data.open[0]
bear_engulf = (self.data.open[0] > self.data.close[-1] and
self.data.close[0] < self.data.open[-1])
overbought = self.rsi[0] > self.p.rsi_overbought
if prev_bull and curr_bear and bear_engulf and overbought:
self.order = self.sell()Practical note: this structure allows only one open position at a time and ignores new signals until the existing trailing stop closes the trade.
we used ETH-USD hourly data from Yahoo Finance. When the download returns MultiIndex columns, we correctly drop the second level.
import yfinance as yf
import pandas as pd
asset = "ETH-USD"
df = yf.download(asset, start="2025-01-01", interval="1h", progress=False)
if isinstance(df.columns, pd.MultiIndex):
df.columns = df.columns.droplevel(1)
df = df.dropna()Then we pass it into Backtrader:
import backtrader as bt
data_feed = bt.feeds.PandasData(dataname=df)
cerebro = bt.Cerebro()
cerebro.adddata(data_feed)
cerebro.addstrategy(EngulfingPatternStrategy)
cerebro.broker.setcash(100_000)
cerebro.broker.setcommission(commission=0.001)
cerebro.addsizer(bt.sizers.PercentSizer, percents=95)results = cerebro.run()
final_value = cerebro.broker.getvalue()
print(f"Final Value: ${final_value:,.2f}")
print(f"Total Return: {(final_value/100_000 - 1)*100:.2f}%")In our reported run, the strategy ended around $293,751 from $100,000.
Add a minimum body-size filter to avoid tiny “engulfings” caused
by noise. For example require:
\[
|C_t - O_t| > k \cdot ATR_t
\]
for some small \(k\).
Prevent immediate re-entry after stop-out using a cooldown (e.g., wait \(n\) bars after exit).
Model slippage and spread. Crypto on hourly bars can still suffer from execution friction, especially around volatile candles.
Separate long-only vs short-only evaluation. Many venues and backtest assumptions differ for shorting spot crypto.
This Engulfing + RSI strategy is a structured reversal system:
Engulfing pattern suggests a potential turning point.
RSI confirms the market is stretched (\(RSI<30\) or \(RSI>70\)).
Trailing stops control downside and keep exits rule-based.
It is a reasonable framework, but the high signal count mean we should validate with stricter assumptions: slippage, spread, realistic sizing, and out-of-sample testing.