Why do I lag the signal?

Shift a signal down one row so it sits in front of the return it earns, and see how far the total moves.

A signal computed from today’s close cannot be traded at today’s close. It is known only once the day is over, so the first return it can earn is tomorrow’s. lag(signal) puts it in front of that return.

In this lesson I put the same signal against the same returns twice, unlagged and lagged, on five returns I can check by hand and then on AAA. I also show that signal * lead(ret) is the lagged version written the other way round.

Step 1. Five returns and a signal that already knows

Here is a signal that is 1 on every up day. It is built from the returns themselves, so in hindsight it is perfect.

library(dplyr)

ret    <- c(0.10, -0.05, 0.04, -0.10, 0.06)   # five daily returns
signal <- ifelse(ret > 0, 1, 0)               # 1 on up days, built from those same returns

same_day <- signal * ret                      # the signal earns its own day's return
lagged   <- lag(signal) * ret                 # the signal earns the NEXT day's return

print(data.frame(ret = ret, signal = signal,
                 same_day = same_day, lagged = lagged))
    ret signal same_day lagged
1  0.10      1     0.10     NA
2 -0.05      0     0.00  -0.05
3  0.04      1     0.04   0.00
4 -0.10      0     0.00  -0.10
5  0.06      1     0.06   0.00
# ->     ret signal same_day lagged
# -> 1  0.10      1     0.10     NA
# -> 2 -0.05      0     0.00  -0.05
# -> 3  0.04      1     0.04   0.00
# -> 4 -0.10      0     0.00  -0.10
# -> 5  0.06      1     0.06   0.00

lag() moves every value down one row. The 1 that row 1 read off its own return now sits on row 2, where it meets the next return instead.

Read the two columns down. same_day keeps every gain and drops every loss. lagged keeps the losses, because the signal only turns on after an up day and this series alternates.

Step 2. Compound each column

prod(1 + r) - 1 from Lesson 8 turns each column into one number.

buy_hold <- prod(1 + ret) - 1                       # hold through all five days
sd_total <- prod(1 + same_day) - 1                  # the unlagged version
lg_total <- prod(1 + lagged[-1]) - 1                # drop the leading NA first

print(round(100 * c(same_day = sd_total,            # as percentages, rounded
                    lagged   = lg_total,
                    hold     = buy_hold), 4))
same_day   lagged     hold 
 21.2640 -14.5000   3.6807 
# -> same_day   lagged     hold
# ->  21.2640 -14.5000   3.6807

Same signal, same returns, 21.26% against -14.50%. The gap is entirely the row the signal sits on.

Step 3. The same thing on AAA

prices.csv sits next to this lesson and holds simulated daily closes for four tickers from 2020 to 2025. The rule is the one from Lesson 23: hold when the close is above its own 50-day average.

library(zoo)

p      <- read.csv("prices.csv", stringsAsFactors = FALSE)
p$Date <- as.Date(p$Date)

aapl  <- p |> filter(Ticker == "AAA") |> arrange(Date)   # one ticker, oldest first
close <- aapl$Close
ret   <- c(NA, diff(close) / head(close, -1))             # simple return per day

sma50 <- rollmean(close, 50, fill = NA, align = "right")  # window ends on the current row
sig   <- ifelse(close > sma50, 1, 0)                      # 1 above the average, 0 below

same_day <- sig * ret                                     # traded on today's own close
lagged   <- lag(sig) * ret                                # traded one day after the close

print(round(100 * c(
  same_day = prod(1 + same_day[!is.na(same_day)]) - 1,    # drop the warm-up NA rows
  lagged   = prod(1 + lagged[!is.na(lagged)]) - 1,
  hold     = prod(1 + ret[-1]) - 1), 2))
same_day   lagged     hold 
 1162.54    58.61   165.45 
# -> same_day   lagged     hold
# ->  1162.54    58.61   165.45

58.61% is what this rule would have earned. 1162.54% is what you get by letting each day’s position be chosen with that day’s close already in hand. Buy and hold returned 165.45% over the same stretch, so the rule lost to holding the stock.

Step 4. The same answer written the other way

Instead of moving the signal forward into tomorrow, you can move the return backwards to meet today’s signal. lead() from Lesson 17 does that.

next_day <- sig * lead(ret)                     # today's signal earns tomorrow's return

print(sum(!is.na(lagged)))                      # -> [1] 1516
[1] 1516
print(sum(!is.na(next_day)))                    # -> [1] 1516
[1] 1516
print(round(100 * c(
  lagged   = prod(1 + lagged[!is.na(lagged)]) - 1,
  next_day = prod(1 + next_day[!is.na(next_day)]) - 1), 4))
  lagged next_day 
 58.6106  58.6106 
# ->   lagged next_day
# ->  58.6106  58.6106

Every product is identical, it just sits one row higher. Both say the position taken on one day earns the return of the next.

Pick one and keep it. I use lag(signal), because the lagged column is the position held on that date, and everything later, from costs to drawdown, is computed date by date.

The rule: a signal built from data up to and including day t earns the return of day t + 1.

Your turn

Run the same comparison on CCC with a 100-day average. How far apart are the two totals, and how does the lagged one compare with buy and hold?

msft <- p |> filter(Ticker == "CCC") |> arrange(Date)   # one ticker, oldest first
mc   <- msft$Close
mr   <- c(NA, diff(mc) / head(mc, -1))                   # simple return per day

ms  <- ifelse(mc > rollmean(mc, 100, fill = NA, align = "right"), 1, 0)
msd <- ms * mr                                           # unlagged
mlg <- lag(ms) * mr                                      # lagged

print(round(100 * c(
  same_day = prod(1 + msd[!is.na(msd)]) - 1,
  lagged   = prod(1 + mlg[!is.na(mlg)]) - 1,
  hold     = prod(1 + mr[-1]) - 1), 2))
# -> same_day   lagged     hold
# ->   819.93   289.59   416.98

289.59% against 819.93%, and buy and hold returned 416.98%.