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
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.
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.
[1] 0 5 0
[1] 5
pmax(x, 0) keeps the positive parts and turns everything else into zero. That is exactly what separating gains from losses needs.
diff() gives today’s close minus yesterday’s. Twelve closes give eleven differences.
[1] 12
[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.
[1] 2 0 2 2 0 2 2 0 2 1 0
[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.
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
[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.
[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 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.
[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.
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.
[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 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.
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
Same twelve numbers as Step 4, from one call.
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
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.
summary() gives the shape of the column in one line.
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.
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.
low high obs
37 95 1552
[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.
[1] 11.53 20.28
[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.
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 164CCC’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.