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.
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.
- Near 100: recent candles mostly went up.
- Near 0: recent candles mostly went down.
- 70 and 30 are the usual "stretched" lines, called Overbought and oversold.
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:
- diff() is each close minus the one before.
- clip splits those changes into gains and losses.
- ewm(alpha=1/14) is a smoothed average that gives recent candles more weight. It's the classic way RSI is smoothed.
- The last line turns "average gain vs average loss" into a 0–100 score.
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:
- Filter: only look for buys when the close is above its 200-candle average — the bigger picture is pointing up.
- Entry: RSI was under 30 and has just closed back above 30. We wait for the turn instead of buying the fall.
- Stop: just under the lowest low of the last 10 candles — the last place price turned around.
- Target: 2 times the risk.
- 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:
- The code runs end to end.
- Signals, stops and fees behave as you meant.
- How many trades the rule makes. Here the filter plus the turn makes it rare: on our 3,000 candles we got only a handful. A handful of trades can look great or terrible by pure chance — you'd need far more data to get 100 trades.
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:
- When a candle closes, fetch the finished candles.
- Recalculate RSI and the 200 average.
- 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.
- Log every order and every skipped signal.
- 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
- Buying just because RSI is low. In a downtrend it can stay low. Wait for a turn and check the bigger chart.
- No stop. "It's oversold, it has to bounce" is how a small loss gets big.
- Not checking the RSI math. Test it on prices where you know the answer.
- Testing on the forming candle. RSI changes until the candle closes.
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 anytimeOr 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: