How do I compute RSI?

Build the relative strength index from scratch: split price changes into up moves and down moves, smooth each with Wilder’s method, and put the ratio on a 0 to 100 scale.

RSI 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 equal amounts of each gives 50.

In this lesson I build it step by step on a twelve-price vector where every number is checkable, wrap the steps in a function rsi(close, n = 14), and run it on AAA from the price file. This is the indicator only. No buying, no selling.

Step 1. pmax against max

The recipe needs the up part and the down part of each price change kept apart. pmax() does that. It compares element by element and returns a vector the same length as its inputs. max() compares everything at once and returns one number.

x <- c(-2, 5, -1)    # three numbers, two of them negative

print(pmax(x, 0))    # -> [1] 0 5 0   <- one answer per element, negatives clipped to 0
[1] 0 5 0
print(max(x, 0))     # -> [1] 5       <- the single largest number in the lot
[1] 5

pmax(x, 0) keeps the positive parts and turns everything else into zero. That is exactly what separating gains from losses needs.

Step 2. Up moves and down moves

diff() gives today’s close minus yesterday’s. Twelve closes give eleven differences.

close <- c(100, 102, 101, 103, 105, 104, 106, 108, 107, 109, 110, 108)

print(length(close))   # -> [1] 12
[1] 12
delta <- diff(close)   # consecutive price changes
print(delta)
 [1]  2 -1  2  2 -1  2  2 -1  2  1 -2
# ->  [1]  2 -1  2  2 -1  2  2 -1  2  1 -2

Now split the changes in two. Gains keep the positive changes and zero out the rest. Losses do the mirror image, and I flip the sign first so a loss is stored as a positive size.

gain <- pmax(delta, 0)    # up moves, down days become 0
loss <- pmax(-delta, 0)   # down moves as positive sizes, up days become 0

print(gain)
 [1] 2 0 2 2 0 2 2 0 2 1 0
# ->  [1] 2 0 2 2 0 2 2 0 2 1 0
print(loss)
 [1] 0 1 0 0 1 0 0 1 0 0 2
# ->  [1] 0 1 0 0 1 0 0 1 0 0 2

Every day lands in one column or the other, never both. Total gains minus total losses returns the whole price move.

print(sum(gain) - sum(loss))             # -> [1] 8
[1] 8
print(close[length(close)] - close[1])   # -> [1] 8
[1] 8

Step 3. Wilder smoothing

Wilder averages the gains and the losses with an exponential moving average whose weight is alpha = 1/n: today counts 1/n, and everything before it carries the remaining (n-1)/n. It is seeded with a plain mean of the first n values, then rolled forward one day at a time.

I use n = 5 here so the whole thing fits on the screen. The two averages live in vectors of NA that get filled in from slot 5 onwards.

n <- 5

avg_gain <- rep(NA_real_, length(delta))   # one slot per price change
avg_loss <- rep(NA_real_, length(delta))

avg_gain[n] <- mean(gain[1:n])             # seed with the mean of the first 5 up moves
avg_loss[n] <- mean(loss[1:n])             # seed with the mean of the first 5 down moves

print(avg_gain[n])                         # -> [1] 1.2
[1] 1.2
print(avg_loss[n])                         # -> [1] 0.4
[1] 0.4

The loop from Lesson 6 carries it forward. Each step is yesterday’s average weighted (n-1)/n plus today’s value weighted 1/n.

for (i in seq(n + 1, length(delta))) {                        # slot 6 up to slot 11
  avg_gain[i] <- (avg_gain[i - 1] * (n - 1) + gain[i]) / n    # 4/5 old, 1/5 new
  avg_loss[i] <- (avg_loss[i - 1] * (n - 1) + loss[i]) / n    # same weights on losses
}

print(round(avg_gain, 4))
 [1]     NA     NA     NA     NA 1.2000 1.3600 1.4880 1.1904 1.3523 1.2819
[11] 1.0255
# ->  [1]     NA     NA     NA     NA 1.2000 1.3600 1.4880 1.1904 1.3523 1.2819
# -> [11] 1.0255
print(round(avg_loss, 4))
 [1]     NA     NA     NA     NA 0.4000 0.3200 0.2560 0.4048 0.3238 0.2591
[11] 0.6073
# ->  [1]     NA     NA     NA     NA 0.4000 0.3200 0.2560 0.4048 0.3238 0.2591
# -> [11] 0.6073

Slot 6 written out by hand matches what the loop stored.

print(round(avg_gain[6], 6))                       # -> [1] 1.36
[1] 1.36
print(round((avg_gain[5] * 4 + gain[6]) / 5, 6))   # -> [1] 1.36
[1] 1.36

Nothing older than slot 5 ever drops out. A rolling mean forgets a value once it leaves the window; this one keeps shrinking its weight instead.

Step 4. From two averages to 0 to 100

The ratio of the two averages is relative strength. Dividing it into 100 - 100/(1 + rs) squashes any positive ratio into the range 0 to 100.

rs  <- avg_gain / avg_loss     # average up size over average down size
val <- 100 - 100 / (1 + rs)    # onto the 0 to 100 scale

print(round(rs, 4))
 [1]     NA     NA     NA     NA 3.0000 4.2500 5.8125 2.9407 4.1759 4.9479
[11] 1.6887
# ->  [1]     NA     NA     NA     NA 3.0000 4.2500 5.8125 2.9407 4.1759 4.9479
# -> [11] 1.6887
print(round(val, 2))
 [1]    NA    NA    NA    NA 75.00 80.95 85.32 74.62 80.68 83.19 62.81
# ->  [1]    NA    NA    NA    NA 75.00 80.95 85.32 74.62 80.68 83.19 62.81

Slot 5 has up moves three times the size of the down moves, and 100 - 100/4 is 75. A ratio of 1 would give 50. A ratio of 0 gives 0, and if there are no down moves at all the ratio is Inf and the formula returns 100.

One alignment job is left. delta is one shorter than close, so a leading NA puts each value back on the day it belongs to.

out <- c(NA, val)      # first close has no change before it

print(length(close))   # -> [1] 12
[1] 12
print(length(out))     # -> [1] 12
[1] 12
print(round(out, 2))
 [1]    NA    NA    NA    NA    NA 75.00 80.95 85.32 74.62 80.68 83.19 62.81
# ->  [1]    NA    NA    NA    NA    NA 75.00 80.95 85.32 74.62 80.68 83.19 62.81

Step 5. The same steps as a function

Every step above goes into one function, in the shape of Lesson 9. n = 14 is the default, so rsi(close) and rsi(close, 14) mean the same thing.

rsi <- function(close, n = 14) {
  delta <- diff(close)                                          # price changes
  gain  <- pmax(delta, 0)                                       # up part
  loss  <- pmax(-delta, 0)                                      # down part

  avg_gain <- rep(NA_real_, length(delta))
  avg_loss <- rep(NA_real_, length(delta))
  avg_gain[n] <- mean(gain[1:n])                                # seed
  avg_loss[n] <- mean(loss[1:n])

  for (i in seq(n + 1, length(delta))) {                        # Wilder's roll forward
    avg_gain[i] <- (avg_gain[i - 1] * (n - 1) + gain[i]) / n
    avg_loss[i] <- (avg_loss[i - 1] * (n - 1) + loss[i]) / n
  }

  c(NA, 100 - 100 / (1 + avg_gain / avg_loss))                  # leading NA restores the length
}

print(round(rsi(close, n = 5), 2))
 [1]    NA    NA    NA    NA    NA 75.00 80.95 85.32 74.62 80.68 83.19 62.81
# ->  [1]    NA    NA    NA    NA    NA 75.00 80.95 85.32 74.62 80.68 83.19 62.81

Same twelve numbers as Step 4, from one call.

Step 6. RSI(14) on AAA

prices.csv sits next to this lesson and holds simulated closes for four tickers over six years. read.csv() returns Date as character, so as.Date() makes it a real date, and arrange(Date) puts the rows oldest first because diff() reads them in row order.

library(dplyr)

px      <- read.csv("prices.csv")          # simulated daily closes
px$Date <- as.Date(px$Date)                # character to date

aapl <- px |>
  filter(Ticker == "AAA") |>              # one ticker
  arrange(Date) |>                         # oldest first, diff walks down the rows
  mutate(rsi14 = rsi(Close, n = 14))       # the indicator column

print(nrow(aapl))                          # -> [1] 1566
[1] 1566
print(aapl[13:17, c("Date", "Close", "rsi14")])
         Date Close    rsi14
13 2020-01-17 73.51       NA
14 2020-01-20 74.35       NA
15 2020-01-21 73.93 45.95924
16 2020-01-22 73.99 46.20352
17 2020-01-23 74.14 46.85035
# ->          Date Close    rsi14
# -> 13 2020-01-17 73.51       NA
# -> 14 2020-01-20 74.35       NA
# -> 15 2020-01-21 73.93 45.95924
# -> 16 2020-01-22 73.99 46.20352
# -> 17 2020-01-23 74.14 46.85035

The first value lands on row 15. Row 1 has no change to measure, and the next thirteen are used up seeding the two averages, so n rows come back NA.

print(sum(is.na(aapl$rsi14)))   # -> [1] 14    <- rows 1 to 14, one per period of the seed
[1] 14

summary() gives the shape of the column in one line.

print(summary(aapl$rsi14))
   Min. 1st Qu.  Median    Mean 3rd Qu.    Max.    NA's 
  19.60   44.46   52.66   52.40   61.11   81.57      14 
# ->    Min. 1st Qu.  Median    Mean 3rd Qu.    Max.    NA's
# ->   19.60   44.46   52.66   52.40   61.11   81.57      14

Six years of AAA never got above 81.57 or below 19.60, and the middle half of the days sat between 44.46 and 61.11.

Step 7. Counting the extremes

30 and 70 are the levels people quote. Counting how often the column crosses them is sum() on a logical vector, from Lesson 3. na.rm = TRUE is needed because the first fourteen rows are NA.

obs  <- sum(!is.na(aapl$rsi14))              # days that have a value
low  <- sum(aapl$rsi14 < 30, na.rm = TRUE)   # days below 30
high <- sum(aapl$rsi14 > 70, na.rm = TRUE)   # days above 70

print(c(low = low, high = high, obs = obs))
 low high  obs 
  37   95 1552 
# ->  low high  obs
# ->   37   95 1552
print(round(100 * c(low, high) / obs, 2))    # -> [1] 2.38 6.12
[1] 2.38 6.12

37 days below 30 and 95 above 70, out of 1552. Both ends are rare, and the top end came up more often on a stock that rose over the sample.

n controls how much of the past each average carries. A shorter n weights today more, so the line swings further and hits the levels far more often.

aapl <- aapl |> mutate(rsi5 = rsi(Close, n = 5))   # same recipe, shorter memory

print(round(c(sd(aapl$rsi14, na.rm = TRUE), sd(aapl$rsi5, na.rm = TRUE)), 2))
[1] 11.53 20.28
# -> [1] 11.53 20.28
print(c(sum(aapl$rsi5 < 30, na.rm = TRUE), sum(aapl$rsi5 > 70, na.rm = TRUE)))
[1] 246 344
# -> [1] 246 344

RSI(5) has nearly twice the spread of RSI(14) and spends 590 days outside the levels instead of 132. Same prices, same formula, different n.

Your turn

Run rsi(Close, n = 14) on CCC from prices.csv. Print summary() of the column, and count the days below 30 and above 70.

library(dplyr)

px      <- read.csv("prices.csv")
px$Date <- as.Date(px$Date)

msft <- px |>
  filter(Ticker == "CCC") |>            # one ticker
  arrange(Date) |>                       # oldest first
  mutate(rsi14 = rsi(Close, n = 14))     # reuse the function from Step 5

print(summary(msft$rsi14))
# ->    Min. 1st Qu.  Median    Mean 3rd Qu.    Max.    NA's
# ->   23.32   45.48   55.81   54.71   64.19   86.02      14

print(c(low  = sum(msft$rsi14 < 30, na.rm = TRUE),      # days below 30
        high = sum(msft$rsi14 > 70, na.rm = TRUE)))     # days above 70
# ->  low high
# ->   36  164

CCC’s median sits at 55.81 against AAA’s 52.66, and it spent 164 days above 70 against AAA’s 95.

Lesson 23 takes an indicator column like this one and turns it into a position.