← Back to Home
Engulfing Pattern with RSI in Backtrader - A Practical Reversal Strategy

Engulfing Pattern with RSI in Backtrader - A Practical Reversal Strategy

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:

  1. Bullish/Bearish Engulfing detection

  2. RSI confirmation filter

  3. Trailing stop-loss exits

  4. A simple ETH-USD 1h backtest workflow

Strategy logic

Bullish Engulfing entry

A bullish entry is triggered when all of the following are true:

Bearish Engulfing entry

A bearish entry is triggered when:

Exits: trailing stop-loss

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:

Implementation in Backtrader

1) Strategy class

Below is a clean, standalone version of the strategy. It does three things:

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.

Data download (yfinance) and Backtrader feed

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)

Running the backtest

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.

Pasted image 20260210040514.png

Common improvements (high impact)

  1. 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\).

  2. Prevent immediate re-entry after stop-out using a cooldown (e.g., wait \(n\) bars after exit).

  3. Model slippage and spread. Crypto on hourly bars can still suffer from execution friction, especially around volatile candles.

  4. Separate long-only vs short-only evaluation. Many venues and backtest assumptions differ for shorting spot crypto.

Conclusion

This Engulfing + RSI strategy is a structured reversal system:

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.