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, laglibrary(zoo) # rollmeanpx <-read.csv("prices.csv") # simulated closes, four tickers, six yearspx$Date <-as.Date(px$Date) # character to Dateaapl <- px |>filter(Ticker =="AAA") |># one tickerarrange(Date) # oldest first, so lag reaches back a dayclose <- aapl$Close # the closes as a plain vectorprint(length(close)) # -> [1] 1566
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 todaysignal <-as.numeric(close > sma50) # 1 above the average, 0 belowprint(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 keptdata.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.
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.
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 curveprint(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 turnoverprint(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 27windows <-c(20, 50, 100, 150, 200)sweep <-NULL# grows one row per windowfor (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 percentsharpe =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 marketrow <-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 stackssweep <- sweep[order(-sweep$total), ] # best total firstprint(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.
TipShow answer
msft <- px |>filter(Ticker =="CCC") |># swap the tickerarrange(Date) |>pull(Close) # the closes as a vectorout <-NULLfor (w inc(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 marketout <-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.