StructureX

Home › Guides › Trading bots & code

RSI bot in Python: code the rule, check the math, test it honestly

"Buy when RSI is under 30" sounds like a whole strategy. In code, you find out fast that it isn't. Here's how to turn it into a full rule with a stop, a size and an honest test.

Beginner guide · 11-minute read · Updated 2026-10-21

What RSI measures

RSI stands for Relative Strength Index. It's a number from 0 to 100 that compares the size of recent up-moves with recent down-moves.

Here's the catch most guides skip. "Oversold" means price fell a lot. It doesn't mean price will turn up. In a strong downtrend, RSI can sit under 30 for a long time. The full story is in RSI explained.

Example code for learning — test it on paper first; it is not a ready-made trading system.

Step 1: Practice data

As on every page in this series, we use made-up prices. No account, no keys, no real money.

import numpy as np
import pandas as pd
# 3,000 fake candles from a random walk. Made-up data, not a real market.
rng = np.random.default_rng(seed=21)
close = 100 + rng.normal(0, 0.5, 3000).cumsum()
open_ = np.r_[close[0], close[:-1]]
wiggle = np.abs(rng.normal(0, 0.3, 3000))
df = pd.DataFrame({
    "open": open_,
    "high": np.maximum(open_, close) + wiggle,
    "low": np.minimum(open_, close) - wiggle,
    "close": close,
})

Step 2: Calculate RSI with pandas

def rsi(closes, length=14):
    change = closes.diff()
    gain = change.clip(lower=0)          # up-moves (down-moves become 0)
    loss = -change.clip(upper=0)         # down-moves, as positive numbers
    avg_gain = gain.ewm(alpha=1 / length, adjust=False).mean()
    avg_loss = loss.ewm(alpha=1 / length, adjust=False).mean()
    return 100 - 100 / (1 + avg_gain / avg_loss)
df["rsi"] = rsi(df["close"])

Line by line:

The first few dozen values are a warm-up. After that, the numbers come out very close to TradingView's RSI. Small differences early on come from how the average is started.

Step 3: Check your indicator before you trust it

A bug in an indicator quietly ruins everything built on top. So test it on prices where you know the answer.

always_up = pd.Series(range(1, 50), dtype=float)
zigzag = pd.Series([100.0, 101.0] * 25)
print(round(rsi(always_up).iloc[-1], 1))   # every candle up: expect 100
print(round(rsi(zigzag).iloc[-1], 1))      # equal ups and downs: expect about 50

If every candle goes up, RSI must be 100. If ups and downs are equal, it should sit near 50. Ours passes both. Do this for every indicator you code.

Step 4: Turn "oversold" into a full rule

"RSI under 30" is only an idea. A rule needs an entry, a stop, a target and a filter. Here's one written out:

  1. Filter: only look for buys when the close is above its 200-candle average — the bigger picture is pointing up.
  2. Entry: RSI was under 30 and has just closed back above 30. We wait for the turn instead of buying the fall.
  3. Stop: just under the lowest low of the last 10 candles — the last place price turned around.
  4. Target: 2 times the risk.
  5. Size: from the stop, 1% of the balance.
trend_up = df["close"] > df["close"].rolling(200).mean()
turn_up = (df["rsi"].shift(1) < 30) & (df["rsi"] >= 30)
signal = trend_up & turn_up
stop_level = df["low"].rolling(10).min() - 0.05
df["enter"] = signal.shift(1, fill_value=False)    # act on the NEXT candle's open
df["stop"] = stop_level.shift(1)                   # stop known at the signal's close
print("Signals:", int(signal.sum()))

The two shift(1) lines matter most. The signal is only known when a candle closes. So we buy at the next candle's open, with the stop we could see at that moment. Without the shift, the test buys at prices that were already gone. That's Backtesting's most common cheat, called look-ahead.

Step 5: Backtest in units of risk, with fees

def backtest(df, rr=2.0, fee=0.05):
    """One trade at a time. Results in R (1R = entry minus stop)."""
    results, i, n = [], 0, len(df)
    while i < n:
        if not df["enter"].iloc[i]:
            i += 1
            continue
        entry, stop = df["open"].iloc[i], df["stop"].iloc[i]
        risk = entry - stop
        if not risk > 0:
            i += 1
            continue
        target, result = entry + rr * risk, None
        for j in range(i, n):
            if df["low"].iloc[j] <= stop:          # stop first: the cautious choice
                result = -1.0
                break
            if df["high"].iloc[j] >= target:
                result = rr
                break
        if result is None:
            j = n - 1
            result = (df["close"].iloc[j] - entry) / risk
        results.append(result - fee / risk)        # costs, in units of risk
        i = j + 1
    return pd.Series(results)
r = backtest(df)
print(f"Trades: {len(r)}  Win rate: {(r > 0).mean():.0%}  Average: {r.mean():+.2f}R")

"R" is one unit of risk: the gap between entry and stop. A result of +2R means the trade gained twice what it risked. −1R means the stop was hit. Reporting in R lets you compare rules without talking about money.

The fee line takes spread and commission off every trade. On a tight stop, the same fee is a bigger slice of R.

What the result means: nothing (on purpose)

The prices are random. No rule can have a real edge on random prices, so whatever total you get is luck. Results on synthetic data mean nothing about real markets.

What the run does tell you:

Then test it on real past data, and after that on paper. Even then, past results don't promise future ones.

Make it a bot

The bot version does the same thing one candle at a time:

  1. When a candle closes, fetch the finished candles.
  2. Recalculate RSI and the 200 average.
  3. If the rule fires and you're flat, size the trade from the stop and place it on a paper account, with the stop attached.
  4. Log every order and every skipped signal.
  5. Stop for the day after −2R.

The loop shape is the same as in the moving average crossover bot. The safety parts are in trading bot risk controls, and the position size calculator checks the size math.

Change one number at a time. Try RSI 14 vs 10, or the 30 line vs 25. Log each run. If small changes flip the result, the rule is fitting noise.

Common mistakes

Questions

How do you calculate RSI in Python?

Take the change in each close, split it into gains and losses, smooth each with an exponential average (alpha = 1/14 for RSI 14), then compute 100 − 100 ÷ (1 + average gain ÷ average loss).

Is buying when RSI is below 30 a good strategy?

On its own it's an idea, not a strategy. RSI can stay below 30 in a downtrend. It needs a stop, a size, a filter and honest testing before it's a rule.

Why is my Python RSI different from TradingView's?

Usually the warm-up. Smoothed averages depend on where they start, so early values differ. After a few dozen candles they should match closely. Also check you're using closed candles.

Can an RSI bot run by itself?

A bot can place orders automatically, but it still needs watching: daily loss limits, error handling and checking the log. Test on paper first.

Want the next step? We'll walk you through it.

Four free lessons by email, one every few days — each with a 10-minute exercise on a real chart.

Send Me the Free Lessons 4 short emails · No spam · Unsubscribe anytime

Or see the whole method, step by step:

See What’s Inside the Course Try everything free first. Decide when you're ready. · Open the free chart tool (works in the TradingView phone app)

One chart idea a day, in plain English. Follow along:

Follow on XFollow on TradingView