How do I compute RSI?

Build the relative strength index from daily changes, Wilder smoothing, and the 0 to 100 formula.

The relative strength index compares the size of recent up moves to the size of recent down moves and puts the answer on a scale from 0 to 100. All up moves gives 100, all down moves gives 0, and an even split gives 50.

In this lesson I build it from scratch on nine closes I can check by hand, then run RSI(14) on AAPL and count how many days sat below 30 and above 70.

Step 1. Split the daily change into a gain and a loss

diff() gives the change from yesterday to today. clip(lower=0) replaces every negative value with zero, so gain keeps the up moves. Flip the sign first and clip again, and loss keeps the down moves as positive numbers.

import pandas as pd

close = pd.Series([100.0, 102.0, 101.0, 104.0, 103.5, 107.0, 106.0, 110.0, 109.0])

delta = close.diff()
gain  = delta.clip(lower=0)
loss  = (-delta).clip(lower=0)

print(pd.DataFrame({"close": close, "delta": delta, "gain": gain, "loss": loss}))
# ->    close  delta  gain  loss
# -> 0  100.0    NaN   NaN   NaN
# -> 1  102.0    2.0   2.0   0.0
# -> 2  101.0   -1.0   0.0   1.0
# -> 3  104.0    3.0   3.0   0.0
# -> 4  103.5   -0.5   0.0   0.5
# -> 5  107.0    3.5   3.5   0.0
# -> 6  106.0   -1.0   0.0   1.0
# -> 7  110.0    4.0   4.0   0.0
# -> 8  109.0   -1.0   0.0   1.0
   close  delta  gain  loss
0  100.0    NaN   NaN   NaN
1  102.0    2.0   2.0   0.0
2  101.0   -1.0   0.0   1.0
3  104.0    3.0   3.0   0.0
4  103.5   -0.5   0.0   0.5
5  107.0    3.5   3.5   0.0
6  106.0   -1.0   0.0   1.0
7  110.0    4.0   4.0   0.0
8  109.0   -1.0   0.0   1.0

Every row has a number in exactly one of the two columns and a zero in the other. Row 0 is NaN because there is no day before it.

Step 2. Smooth both with Wilder’s average

Wilder’s average is an exponentially weighted mean with alpha = 1 / n. That is ewm from Lesson 26 with adjust=False, which runs the recursion today = (1 - alpha) * yesterday + alpha * new. min_periods=n holds the output back until n real values have gone in.

I use n = 3 here so the numbers stay small.

n = 3

avg_gain = gain.ewm(alpha=1/n, adjust=False, min_periods=n).mean()
avg_loss = loss.ewm(alpha=1/n, adjust=False, min_periods=n).mean()

print(pd.DataFrame({"avg_gain": avg_gain, "avg_loss": avg_loss}).round(4))
# ->    avg_gain  avg_loss
# -> 0       NaN       NaN
# -> 1       NaN       NaN
# -> 2       NaN       NaN
# -> 3    1.8889    0.2222
# -> 4    1.2593    0.3148
# -> 5    2.0062    0.2099
# -> 6    1.3374    0.4733
# -> 7    2.2250    0.3155
# -> 8    1.4833    0.5437
   avg_gain  avg_loss
0       NaN       NaN
1       NaN       NaN
2       NaN       NaN
3    1.8889    0.2222
4    1.2593    0.3148
5    2.0062    0.2099
6    1.3374    0.4733
7    2.2250    0.3155
8    1.4833    0.5437

Step 3. Turn the two averages into a number between 0 and 100

Divide the average gain by the average loss and feed the ratio into 100 - 100 / (1 + ratio).

rsi = 100 - 100 / (1 + avg_gain / avg_loss)

print(pd.DataFrame({"close": close, "rsi": rsi}).round(2))
# ->    close    rsi
# -> 0  100.0    NaN
# -> 1  102.0    NaN
# -> 2  101.0    NaN
# -> 3  104.0  89.47
# -> 4  103.5  80.00
# -> 5  107.0  90.53
# -> 6  106.0  73.86
# -> 7  110.0  87.58
# -> 8  109.0  73.18
   close    rsi
0  100.0    NaN
1  102.0    NaN
2  101.0    NaN
3  104.0  89.47
4  103.5  80.00
5  107.0  90.53
6  106.0  73.86
7  110.0  87.58
8  109.0  73.18

Row 3 by hand: the average gain is 8.5 times the average loss, and 100 - 100 / 9.5 is 89.4737.

ratio = avg_gain.iloc[3] / avg_loss.iloc[3]

print(round(ratio, 4))                        # -> 8.5
print(round(100 - 100 / (1 + ratio), 4))      # -> 89.4737
print(round(rsi.iloc[3], 4))                  # -> 89.4737
8.5
89.4737
89.4737

The formula squashes any ratio into 0 to 100. A ratio of 1 gives 50. A ratio of 0, meaning no up moves at all, gives 0. If there are no down moves the average loss is 0, the ratio is infinite, and the answer lands on exactly 100.

up = pd.Series([100.0, 101.0, 102.0, 103.0, 104.0])
d  = up.diff()

ag = d.clip(lower=0).ewm(alpha=1/3, adjust=False, min_periods=3).mean()
al = (-d).clip(lower=0).ewm(alpha=1/3, adjust=False, min_periods=3).mean()

print(al.iloc[-1])                            # -> 0.0
print((100 - 100 / (1 + ag / al)).iloc[-1])   # -> 100.0
0.0
100.0

Step 4. RSI(14) on AAPL

Same four lines wrapped in a function, run on the AAPL closes in prices.csv, which holds simulated daily data for four tickers from 2020 to 2025.

def rsi_wilder(close, n=14):
    delta = close.diff()
    gain  = delta.clip(lower=0)
    loss  = (-delta).clip(lower=0)
    avg_gain = gain.ewm(alpha=1/n, adjust=False, min_periods=n).mean()
    avg_loss = loss.ewm(alpha=1/n, adjust=False, min_periods=n).mean()
    return 100 - 100 / (1 + avg_gain / avg_loss)


prices = pd.read_csv("prices.csv", parse_dates=["Date"])
aapl   = prices[prices["Ticker"] == "AAPL"].set_index("Date").sort_index()
close  = aapl["Close"]

rsi14 = rsi_wilder(close, 14)

print(len(close))                     # -> 1566
print(rsi14.isna().sum())             # -> 14    <- diff eats one day, min_periods holds back 13 more

print(rsi14.dropna().head(3).round(2))
# -> Date
# -> 2020-01-21    69.40
# -> 2020-01-22    69.51
# -> 2020-01-23    69.80
# -> Name: Close, dtype: float64
1566
14
Date
2020-01-21    69.40
2020-01-22    69.51
2020-01-23    69.80
Name: Close, dtype: float64

describe() shows where the readings sit.

print(rsi14.describe().round(2))
# -> count    1552.00
# -> mean       52.65
# -> std        11.61
# -> min        19.60
# -> 25%        44.64
# -> 50%        52.92
# -> 75%        61.62
# -> max        81.57
# -> Name: Close, dtype: float64
count    1552.00
mean       52.65
std        11.61
min        19.60
25%        44.64
50%        52.92
75%        61.62
max        81.57
Name: Close, dtype: float64

The mean sits just above 50, which is what a price that drifted up over six years gives. The readings stay bunched: half of them fall between 44.6 and 61.6.

Step 5. Count the days below 30 and above 70

30 and 70 are the levels RSI is usually read against. Counting them is a boolean mask from Lesson 13 and a .sum().

valid = rsi14.dropna()

below = valid < 30
above = valid > 70

print(len(valid))                  # -> 1552
print(below.sum(), above.sum())    # -> 37 98

print(f"{below.mean():.2%}")       # -> 2.38%
print(f"{above.mean():.2%}")       # -> 6.31%
1552
37 98
2.38%
6.31%

Neither level is crossed often. The lowest reading in the sample is 19.60.

print(rsi14.idxmin().date(), round(rsi14.min(), 2))    # -> 2023-06-23 19.6
print(rsi14.idxmax().date(), round(rsi14.max(), 2))    # -> 2022-05-03 81.57

print(pd.DataFrame({"close": close, "rsi14": rsi14}).loc["2023-06-19":"2023-06-27"].round(2))
# ->              close  rsi14
# -> Date
# -> 2023-06-19  100.95  22.73
# -> 2023-06-20  100.53  22.17
# -> 2023-06-21   99.83  21.23
# -> 2023-06-22   98.61  19.66
# -> 2023-06-23   98.56  19.60
# -> 2023-06-26  101.48  33.23
# -> 2023-06-27  101.35  32.96
2023-06-23 19.6
2022-05-03 81.57
             close  rsi14
Date                     
2023-06-19  100.95  22.73
2023-06-20  100.53  22.17
2023-06-21   99.83  21.23
2023-06-22   98.61  19.66
2023-06-23   98.56  19.60
2023-06-26  101.48  33.23
2023-06-27  101.35  32.96

One up day of 2.92 on 2023-06-26 moves the reading from 19.60 to 33.23. With n = 14 each new day carries a weight of 1/14, and after a run of losses the average gain is close to zero, so a single gain lifts the ratio a long way.

Step 6. The window sets the spread

A shorter window puts more weight on the newest day, so the readings swing wider and hit the levels more often.

comp = pd.DataFrame({"rsi5": rsi_wilder(close, 5), "rsi14": rsi_wilder(close, 14)})

print(comp.describe().loc[["std", "min", "max"]].round(2))
# ->       rsi5  rsi14
# -> std  20.28  11.61
# -> min   6.48  19.60
# -> max  94.08  81.57

print((comp["rsi5"].dropna() < 30).sum())     # -> 246
print((comp["rsi14"].dropna() < 30).sum())    # -> 37
      rsi5  rsi14
std  20.28  11.61
min   6.48  19.60
max  94.08  81.57
246
37

RSI(5) drops below 30 on 246 days, RSI(14) on 37. Same prices, same formula, different n.

That is the indicator. Turning a level into a position is Lesson 29.

Your turn

Compute RSI(14) on MSFT from prices.csv. What is the mean reading, and how many days fall below 30 and above 70?

import pandas as pd


def rsi_wilder(close, n=14):
    delta = close.diff()
    gain  = delta.clip(lower=0)
    loss  = (-delta).clip(lower=0)
    avg_gain = gain.ewm(alpha=1/n, adjust=False, min_periods=n).mean()
    avg_loss = loss.ewm(alpha=1/n, adjust=False, min_periods=n).mean()
    return 100 - 100 / (1 + avg_gain / avg_loss)


prices = pd.read_csv("prices.csv", parse_dates=["Date"])
msft   = prices[prices["Ticker"] == "MSFT"].set_index("Date").sort_index()

rsi14 = rsi_wilder(msft["Close"], 14).dropna()

print(round(rsi14.mean(), 2))                        # -> 54.72
print((rsi14 < 30).sum(), (rsi14 > 70).sum())        # -> 36 164