How do I put a backtest in one function?

Wrap returns, the lagged position, turnover, costs and the equity curve in one function, run it on a moving-average rule, and sweep the window.

A backtest is six lines of arithmetic. Returns from the closes, a position that is yesterday’s signal, turnover from the change in position, a cost charged on that turnover, the strategy return, and the equity curve. Put those six lines in a function and you can run any signal through them.

In this lesson I write backtest(close, signal, cost_bps = 0), run it on AAA with a price-above-50-day-average rule, then loop over five averaging windows and collect the results in one table.

Step 1. The prices and the signal

The file is simulated. read.csv() returns Date as character, so as.Date() makes it a real date.

library(dplyr)                          # filter, arrange, lag
library(zoo)                            # rollmean

px      <- read.csv("prices.csv")       # simulated closes, four tickers, six years
px$Date <- as.Date(px$Date)             # character to Date

aapl  <- px |>
  filter(Ticker == "AAA") |>           # one ticker
  arrange(Date)                         # oldest first, so lag reaches back a day

close <- aapl$Close                     # the closes as a plain vector

print(length(close))                    # -> [1] 1566
[1] 1566
print(range(aapl$Date))                 # -> [1] "2020-01-01" "2025-12-31"
[1] "2020-01-01" "2025-12-31"

The signal is Lesson 21’s moving average and Lesson 23’s comparison: 1 on the days the close sits above its 50-day average, 0 otherwise.

sma50  <- rollmean(close, 50, fill = NA, align = "right")   # 50-day average, ending today
signal <- as.numeric(close > sma50)                         # 1 above the average, 0 below

print(sum(signal, na.rm = TRUE))        # -> [1] 906    <- days the rule says hold
[1] 906
print(sum(is.na(signal)))               # -> [1] 49     <- days before the average exists
[1] 49

Step 2. The function

Every line inside comes from an earlier lesson. lag() is Lesson 17, and it is what makes the position tradable: today’s position is yesterday’s signal, so a rule you could only see at tonight’s close never earns today’s return. abs(diff(position)) is Lesson 25’s turnover. cumprod(1 + r) is Lesson 18’s equity curve.

backtest <- function(close, signal, cost_bps = 0) {

  ret      <- c(NA, diff(close) / head(close, -1))   # daily simple returns, first day NA
  position <- lag(signal)                            # yesterday's signal, held today
  position[is.na(position)] <- 0                     # flat until the signal exists
  turnover <- c(0, abs(diff(position)))              # 1 on a day the position changes
  earned   <- position * ifelse(is.na(ret), 0, ret)  # return the position actually earned
  cost     <- turnover * cost_bps / 10000            # charged on the change, not the level
  strat    <- earned - cost                          # what the strategy kept

  data.frame(ret             = ret,
             position        = position,
             turnover        = turnover,
             strategy_return = strat,
             equity          = cumprod(1 + strat))   # one unit of money, day by day
}

The function takes vectors and returns a data frame with one row per day, so nothing about the ticker or the rule is baked in.

Step 3. Run it

bt <- backtest(close, signal)   # no costs yet

print(dim(bt))                  # -> [1] 1566    5
[1] 1566    5
print(head(bt, 3))
          ret position turnover strategy_return equity
1          NA        0        0               0      1
2 0.024773575        0        0               0      1
3 0.005198856        0        0               0      1
# ->           ret position turnover strategy_return equity
# -> 1          NA        0        0               0      1
# -> 2 0.024773575        0        0               0      1
# -> 3 0.005198856        0        0               0      1

The first three days sit flat. The 50-day average does not exist yet, so the signal is NA, the position is 0, and the equity curve stays at 1 while the stock moves.

Here are the rows where the position first turns on.

print(bt[49:53, ])
            ret position turnover strategy_return    equity
49 -0.001907184        0        0      0.00000000 1.0000000
50  0.031592357        0        0      0.00000000 1.0000000
51 -0.016423808        1        1     -0.01642381 0.9835762
52 -0.012680477        1        0     -0.01268048 0.9711040
53 -0.035732452        1        0     -0.03573245 0.9364041
# ->             ret position turnover strategy_return    equity
# -> 49 -0.001907184        0        0      0.00000000 1.0000000
# -> 50  0.031592357        0        0      0.00000000 1.0000000
# -> 51 -0.016423808        1        1     -0.01642381 0.9835762
# -> 52 -0.012680477        1        0     -0.01268048 0.9711040
# -> 53 -0.035732452        1        0     -0.03573245 0.9364041

Row 50 is the first day the close is above its average, and row 50 earns nothing. Row 51 holds the position and takes row 51’s return. turnover is 1 on row 51, the day the position went from 0 to 1, and 0 while it stays there.

final <- bt$equity[nrow(bt)]       # last level of the curve

print(round(final, 4))             # -> [1] 1.5861
[1] 1.5861
print(round((final - 1) * 100, 2)) # -> [1] 58.61
[1] 58.61
print(sum(bt$turnover))            # -> [1] 113    <- position changes over six years
[1] 113

One unit of money became 1.5861, a total of 58.61%, after 113 changes of position.

The cost argument is the only thing left to try. Five basis points a trade, charged on each of those 113 changes:

bt5 <- backtest(close, signal, cost_bps = 5)         # 0.05% per unit of turnover

print(round((bt5$equity[nrow(bt5)] - 1) * 100, 2))   # -> [1] 49.89
[1] 49.89

58.61% becomes 49.89%.

Step 4. Sweep the window

The window was 50 because I picked 50. A loop runs the same function on five windows and rbind() stacks one row per run.

sharpe <- function(r) mean(r) / sd(r) * sqrt(252)   # annualised, Lesson 27

windows <- c(20, 50, 100, 150, 200)
sweep   <- NULL                                     # grows one row per window

for (w in windows) {
  sg  <- as.numeric(close > rollmean(close, w, fill = NA, align = "right"))   # this window's signal
  b   <- backtest(close, sg)                                                  # same function, new signal
  row <- data.frame(rule   = paste("SMA", w),
                    total  = round((b$equity[nrow(b)] - 1) * 100, 2),         # total return in percent
                    sharpe = round(sharpe(b$strategy_return), 2),
                    trades = sum(b$turnover))                                 # position changes
  sweep <- rbind(sweep, row)                                                  # add the row to the table
}

print(sweep)
     rule  total sharpe trades
1  SMA 20 109.32   0.76    173
2  SMA 50  58.61   0.50    113
3 SMA 100  30.79   0.33     95
4 SMA 150  33.59   0.35     63
5 SMA 200  18.26   0.24     69
# ->      rule  total sharpe trades
# -> 1  SMA 20 109.32   0.76    173
# -> 2  SMA 50  58.61   0.50    113
# -> 3 SMA 100  30.79   0.33     95
# -> 4 SMA 150  33.59   0.35     63
# -> 5 SMA 200  18.26   0.24     69

Buy and hold belongs in the same table, and it needs no new code: a signal that is 1 on every day is a position of 1 from the second day on.

bh  <- backtest(close, rep(1, length(close)))   # always in the market
row <- data.frame(rule   = "buy and hold",
                  total  = round((bh$equity[nrow(bh)] - 1) * 100, 2),
                  sharpe = round(sharpe(bh$strategy_return), 2),
                  trades = sum(bh$turnover))

sweep <- rbind(sweep, row)                      # same four columns, so it stacks
sweep <- sweep[order(-sweep$total), ]           # best total first

print(sweep)
          rule  total sharpe trades
6 buy and hold 165.45   0.79      1
1       SMA 20 109.32   0.76    173
2       SMA 50  58.61   0.50    113
4      SMA 150  33.59   0.35     63
3      SMA 100  30.79   0.33     95
5      SMA 200  18.26   0.24     69
# ->           rule  total sharpe trades
# -> 6 buy and hold 165.45   0.79      1
# -> 1       SMA 20 109.32   0.76    173
# -> 2       SMA 50  58.61   0.50    113
# -> 4      SMA 150  33.59   0.35     63
# -> 3      SMA 100  30.79   0.33     95
# -> 5      SMA 200  18.26   0.24     69

The row names are the positions the rows had before sorting, which is why they run 6, 1, 2, 4, 3, 5.

Buy and hold returns 165.45%. The best window is 20, at 109.32%. Every window loses to holding the stock over this sample, and the gap is not close.

backtest() never saw a moving average. It takes a vector of closes and a vector of 0s and 1s, so the RSI rule from Lesson 22, a rule on volume, or any other signal you can write as 0 and 1 runs through the same function unchanged.

Your turn

Run the same sweep on CCC, with buy and hold as a row, and sort by total return.

msft <- px |>
  filter(Ticker == "CCC") |>          # swap the ticker
  arrange(Date) |>
  pull(Close)                          # the closes as a vector

out <- NULL

for (w in c(20, 50, 100, 150, 200)) {
  sg <- as.numeric(msft > rollmean(msft, w, fill = NA, align = "right"))
  b  <- backtest(msft, sg)             # the function is unchanged
  out <- rbind(out, data.frame(rule   = paste("SMA", w),
                               total  = round((b$equity[nrow(b)] - 1) * 100, 2),
                               sharpe = round(sharpe(b$strategy_return), 2)))
}

bhm <- backtest(msft, rep(1, length(msft)))   # always in the market
out <- rbind(out, data.frame(rule   = "buy and hold",
                             total  = round((bhm$equity[nrow(bhm)] - 1) * 100, 2),
                             sharpe = round(sharpe(bhm$strategy_return), 2)))

print(out[order(-out$total), ])
# ->           rule  total sharpe
# -> 6 buy and hold 416.98   1.38
# -> 1       SMA 20 398.81   1.66
# -> 2       SMA 50 390.22   1.57
# -> 5      SMA 200 302.13   1.37
# -> 3      SMA 100 289.59   1.34
# -> 4      SMA 150 257.76   1.25

Buy and hold wins on total return again, but three windows beat it on Sharpe, because they sit out some of the falls.