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 AAA 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()              # change from yesterday to today
gain  = delta.clip(lower=0)       # negatives clipped to 0, up moves only
loss  = (-delta).clip(lower=0)    # sign flipped first, down moves as positives

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    # short window so the numbers stay checkable

avg_gain = gain.ewm(alpha=1/n, adjust=False, min_periods=n).mean()    # Wilder mean of the gains
avg_loss = loss.ewm(alpha=1/n, adjust=False, min_periods=n).mean()    # Wilder mean of the losses

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)    # the ratio squashed onto 0 to 100

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]   # average gain over average loss at row 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])    # five closes that only ever rise
d  = up.diff()                                                              # no negative changes

ag = d.clip(lower=0).ewm(alpha=1/3, adjust=False, min_periods=3).mean()     # gain side, as before
al = (-d).clip(lower=0).ewm(alpha=1/3, adjust=False, min_periods=3).mean()  # loss side, all zeros

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 AAA

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

def rsi_wilder(close, n=14):                       # the same four steps, n as an argument
    delta = close.diff()                           # change from yesterday to today
    gain  = delta.clip(lower=0)                    # up moves only
    loss  = (-delta).clip(lower=0)                 # down moves as positives
    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)   # 0 to 100 in one expression


prices = pd.read_csv("prices.csv", parse_dates=["Date"])
aapl   = prices[prices["Ticker"] == "AAA"].set_index("Date").sort_index()   # AAA rows, date index
close  = aapl["Close"]                             # the AAA close as a Series

rsi14 = rsi_wilder(close, 14)                      # 14-day RSI for every date

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))             # the first three real readings
# -> 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))    # summary of all 1552 readings
# -> 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()             # the 14 warm-up days dropped

below = valid < 30                 # True on each oversold day
above = valid > 70                 # True on each overbought day

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 CCC 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):                         # same function as in Step 4
    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)     # 0 to 100 in one expression


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

rsi14 = rsi_wilder(msft["Close"], 14).dropna()       # warm-up rows removed

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