← Back to list

The RSI & EMA Strategy

QQQ | Daily Timeframe | 10 Year Backtest 114 Trades | 282.76% Return | 79.82% Win Rate

Bubble Analytics · 2026-05-24 20:19 · 1 claps · 6.3 min read
#algorithmic-trading #quantitative-finance #python-trading #backtesting #python-trading-strategies
Open on Medium ↗
Wiki topics: 💻 · Programming

The RSI & EMA Strategy

QQQ | Daily Timeframe | 10 Year Backtest

114 Trades | 282.76% Return | 79.82% Win Rate

This week’s attempt at financial mastery is another dual-indicator strategy built using the Relative Strength Index (RSI) and two Exponential Moving Averages (EMA). The aim of the strategy is to use EMA crossovers to define trend direction, and RSI to confirm momentum shifts and trigger entry and exit points.

To start we have downloaded 10 years worth of daily OHLC bars for the Nasdaq 100 index (QQQ) from Yahoo Finance into a dataframe named df, and the backtest itself will be run using the backtesting.py library. As usual, we will show you how to calculate the indicators, apply them to price, generate trading signals, then run a full backtest.

The completed codebook along with our backtesting template can be downloaded via our GitHub profile, but for more detailed coding instruction you can refer to the video on our YouTube channel which shows you how to construct it from start to finish. Please note that the article strategy is for infotainment purposes only and should not be construed as financial advice.

Creating the Indicators/Overlays

The Relative Strength Index (RSI) measures the speed and strength of recent price movement by comparing average gains to average losses over a chosen lookback period. Traders mostly use it to spot potential reversals, and while it can be good for picking out lows, on the upside it is better at confirming the strength of an existing trend rather than an outright high.

Values above 50 generally indicate bullish momentum, while values below 50 hint at a bearish trend in play. Readings above 70 confirm a strong upside move with the potential for a high, but sustained time above 70 or repeated moves back above 70 from above 50 are more common and typically reflect trend persistence rather than exhaustion.

To recreate RSI we begin by calculating the bar-to-bar difference in Close prices (pc). Positive changes are isolated from negative changes, with the absolute value of negative changes recorded, and the two lists are stored separately (up & dn). These values are then smoothed using an exponential moving average with Wilder-style smoothing (avg_up & avg_dn).

Relative strength (RS) is calculated as the ratio of average gains to average losses, and is then transformed into a bounded oscillator (RSI). For this strategy we create a column in our dataframe (df) for 8 period RSI.

The code is as follows:

def RSI(data, n):
    pc = data["Close"].diff()
    up = pc.clip(lower=0)
    dn = pc.clip(upper=0).abs()
    avg_up = up.ewm(alpha=1/n, adjust=False).mean()
    avg_dn = dn.ewm(alpha=1/n, adjust=False).mean()
    RS = avg_up / avg_dn
    rsi = 100 - (100 / (1 + RS))
    return rsi

df["RSI8"] = RSI(df, 8)

We then calculate two exponential moving averages based on Close prices: a faster 10-period EMA and a slower 30-period EMA. These will be used to define the prevailing trend direction. We create 2 new columns in our dataframe (df) with these averages.

The code looks like this:

df["EMA30"] = df.Close.ewm(span=30).mean()
df["EMA10"] = df.Close.ewm(span=10).mean()

The chart below shows you price above with the moving averages overlaid, and 8 period RSI below:

Signal Generation

In the main, people generate buy signals for RSI values below 30, but we have discarded that approach since RSI with a short lookback period will give you multiple oversold readings during a sustained downtrend. Instead we focus on higher RSI readings to trigger long trades.

A buy signal is generated when EMA10 is above EMA30, confirming bullish trend direction, and RSI crosses upward through the 50 level; a sell signal is generated when EMA10 is below EMA30 and RSI crosses downward through 50, signalling weakening momentum and a possible bearish trend forming or extending.

In addition, a secondary buy signal (type 3) is generated when RSI crosses above 70, with the strategy attempting to capitalise on this upside momentum by using a tighter profit target compared to the primary buy setup. Here we are hoping for a quick win.

For all three signals, .iloc indexing is used to compare the current bar with prior bars to confirm that specific conditions have just occurred. Here, .iloc[i] represents the bar that has just closed, while .iloc[i-1] represents the bar immediately before it. So in the example data.RSI8.iloc[i-1] < 50 and data.RSI8.iloc[i] > 50, the RSI was below 50 on the previous bar and has now closed above 50, confirming an upward cross at that exact point in time.

If none of these buy or sell conditions are met, the signal is classed as zero and no action is taken. The signal function loops through each row of the dataframe looking for our trigger conditions and assigns a value of 1 (primary buy), 2 (sell), 3 (secondary buy), or 0 (do nothing) into a ‘signal’ column.

Our signal code looks like this:

def signal(data):
    signal = [0] * len(df)
    for i in range(0, len(df)):
        if (data.EMA10.iloc[i] > data.EMA30.iloc[i]) & \
        (data.RSI8.iloc[i-1] < 50) & \
        (data.RSI8.iloc[i] > 50):
            signal[i] = 1
        elif (data.EMA10.iloc[i] < data.EMA30.iloc[i]) & \
        (data.RSI8.iloc[i-1] > 50) & \
        (data.RSI8.iloc[i] < 50):
            signal[i] = 2
        elif (data.RSI8.iloc[i-1] < 70) & \
        (data.RSI8.iloc[i] > 70):
            signal[i] = 3
        else:
            signal[i] = 0
        df["signal"] = signal

signal(df)

Running the Backtest

Once our signal column values are populated, we can then run the backtest. We define a SIGNAL function to take in the values of our signal column, then create a strategy class called MyStrat.

Within MyStrat we initialise the SIGNAL function, create a price reference point we can key off to set stop losses and profit targets, and then use if statements to tell the backtesting program what action to take when a buy or sell signal is found.

In this backtest, where the signal equals 1 we buy with 99% of available capital (size=0.99), using a 2% stop loss and a 6% profit target. If signal equals 3 we buy with the same position size, but use a 1.5% profit target and no stop. If signal equals 2 we close any open long position but do not flip short.

When we come to run the backtest, we tell it where to find our signal column values (df), which strategy class we want to run (MyStrat), define the starting capital allocation (cash=100_000), set it to use no leverage (margin=1), set a rule whereby we can only have one open position at a time (exclusive_orders=True), and tell it to factor in a 0.05% commission fee on the buy and sell (for a total fee of 0.1% per completed trade).

The code looks like this:

def SIGNAL():
    return df.signal

class MyStrat(Strategy):

    def init(self):
        super().init()
        self.signal = self.I(SIGNAL)

    def next(self):
        super().next()

        price = self.data.Close[-1]

        if self.signal == 1:
            if self.position.is_short or not self.position:
                self.position.close()
                self.buy(size=0.99, sl=0.98*price, tp=1.06*price)

        elif self.signal == 3:
            if self.position.is_short or not self.position:
                self.position.close()
                self.buy(size=0.99, tp=1.015*price)

        elif self.signal == 2:
            if self.position.is_long or not self.position:
                self.position.close()

bt = Backtest(df, MyStrat, cash=100_000, margin=1, exclusive_orders=True, commission=0.0005)
stats = bt.run()
stats

Strategy Results

Over the 10-year test period the strategy placed 114 trades, achieved a win rate of 79.82%, and returned 282.76% compared to 631.56% for buy-and-hold. Exposure time was 49.18%, meaning the strategy was invested just under half the time.

Maximum drawdown was -21.03%, materially lower than the peak-to-trough losses experienced by QQQ over the same period. The Sharpe ratio of 1.11 and Sortino ratio of 1.87 indicate favourable risk-adjusted performance, with a profit factor of 3.01 and an average trade return of 1.2%. Stats are as follows:

In order to visualise the drawdown profile, a profit and loss graph is included below. Of note is the close to 2 year sideways-to-down period that for sure would have caused consternation when experienced in real time:

We only just have enough trades to model risk, but for completeness a Monte Carlo simulation is also provided, and shows that if all the losing trades came at the start of the strategy, it would still have been underwater in year 3. Overall it is a good looking profile:

Overall the strategy appears reasonably effective at aligning with bullish trend direction while using RSI to refine entry timing. However, the use of fixed profit targets results in underperformance during extended upside runs, and entries often occur once upside momentum has developed and is confirmed rather than at the turning point. It is slow to the party.

As always, further parameter optimisation, regime filtering, or volatility targeting may improve performance. This article strategy is presented for educational purposes only and should not be considered financial advice. For a detailed run through of the codebook, please watch the accompanying video.

[embed]


메타데이터
post_id
2f68af7c3bab
slug
the-rsi-ema-strategy-2f68af7c3bab
url
https://medium.com/@bubble_analytics/the-rsi-ema-strategy-2f68af7c3bab
canonical_url
https://medium.com/@bubble_analytics/the-rsi-ema-strategy-2f68af7c3bab
author_url
https://medium.com/@bubble_analytics
status
ok
fetched_at
2026-06-09 15:37:30