How do I turn an indicator into a position?

Read an indicator with a rule to get a signal, then lag the signal by one row to get the position you hold.

A signal is a rule that reads an indicator and says in or out. A position is what you actually hold, and it is yesterday’s signal: position <- lag(signal). Why the lag is there is Lesson 24.

In this lesson I build a 1 and 0 signal from a moving average rule, lag it into a position, and then do the same for a long and short crossover.

Step 1. Above the average or not

prices.csv sits next to this lesson and holds simulated daily closes for four tickers from 2020 to 2025. ifelse() from Lesson 7 turns the comparison into a number, and lag() from Lesson 17 moves the column down one row.

library(dplyr)
library(zoo)

p       <- read.csv("prices.csv", stringsAsFactors = FALSE)
p$Date  <- as.Date(p$Date)                       # text becomes real dates

aapl  <- p |> filter(Ticker == "AAA") |> arrange(Date)   # one ticker, oldest first
close <- aapl$Close                              # the series every rule below reads
dates <- aapl$Date

sma50    <- rollmean(close, 50, fill = NA, align = "right")  # window ends on the current row
signal   <- ifelse(close > sma50, 1, 0)          # 1 above the average, 0 below
position <- lag(signal)                          # today I hold yesterday's signal

tbl <- data.frame(Date = dates, close = close, sma50 = round(sma50, 2),
                  signal = signal, position = position)

print(tbl[55:62, ])
         Date close sma50 signal position
55 2020-03-17 76.46 76.22      1        0
56 2020-03-18 76.13 76.17      0        1
57 2020-03-19 75.07 76.07      0        0
58 2020-03-20 75.69 76.02      0        0
59 2020-03-23 74.55 75.96      0        0
60 2020-03-24 75.92 75.92      0        0
61 2020-03-25 75.32 75.91      0        0
62 2020-03-26 76.55 75.93      1        0
# ->          Date close sma50 signal position
# -> 55 2020-03-17 76.46 76.22      1        0
# -> 56 2020-03-18 76.13 76.17      0        1
# -> 57 2020-03-19 75.07 76.07      0        0
# -> 58 2020-03-20 75.69 76.02      0        0
# -> 59 2020-03-23 74.55 75.96      0        0
# -> 60 2020-03-24 75.92 75.92      0        0
# -> 61 2020-03-25 75.32 75.91      0        0
# -> 62 2020-03-26 76.55 75.93      1        0

Read the signal and position columns side by side. On 17 March the close sits above the average and the signal is 1. The position is 1 on 18 March, one row later. Every value in position is the value one row above it in signal.

Step 2. The warm-up rows

rollmean needs 50 closes before it produces a number, so the first 49 rows of sma50 are NA. A comparison against NA gives NA, so the signal is NA there too.

print(sum(is.na(sma50)))      # -> [1] 49
[1] 49
print(sum(is.na(signal)))     # -> [1] 49
[1] 49
print(sum(is.na(position)))   # -> [1] 50    <- the 49 plus the row lag() pushed off the top
[1] 50

Nothing is held until the average exists, which is what you want: a rule cannot act before its indicator does.

Step 3. How often the rule was invested

position is 1 on the days the rule held the stock and 0 on the days it did not, so counting the ones gives the share of days invested.

invested <- sum(position == 1, na.rm = TRUE)     # days holding the stock
tradable <- sum(!is.na(position))                # days the rule could act at all

print(c(invested = invested, tradable = tradable))
invested tradable 
     905     1516 
# -> invested tradable
# ->      905     1516

print(round(mean(position, na.rm = TRUE), 3))    # -> [1] 0.597
[1] 0.597

The rule held AAA on 59.7% of the days it could trade.

Multiplying the position by the return gives what the rule earned each day.

ret      <- c(NA, diff(close) / head(close, -1))  # simple return, from Lesson 8
strategy <- position * ret                        # earns only on days I am invested

print(round(strategy[50:56], 6))
[1]        NA -0.016424 -0.012680 -0.035732  0.000000  0.000000 -0.004316
# -> [1]        NA -0.016424 -0.012680 -0.035732  0.000000  0.000000 -0.004316

On a day with a position of 1 the strategy earns the return. On a day with a position of 0 it earns nothing, whichever way the stock moved.

Step 4. Long and short from a crossover

A rule can also produce -1, meaning short. Two exponential averages from Lesson 21, a fast one and a slow one, give 1 or -1 with no flat state.

ema <- function(x, span) {                       # the EMA loop from Lesson 21
  a   <- 2 / (span + 1)                          # mixing weight for this span
  out <- numeric(length(x))                      # somewhere to put the answers
  out[1] <- x[1]                                 # seed with the first close
  for (i in 2:length(x)) {
    out[i] <- a * x[i] + (1 - a) * out[i - 1]    # today mixed into yesterday's average
  }
  out
}

fast  <- ema(close, 20)                          # short EMA, turns quickly
slow  <- ema(close, 50)                          # long EMA, the reference line
cross <- ifelse(fast > slow, 1, -1)              # long above, short below

print(table(cross))                              # count the days in each state
cross
 -1   1 
597 969 
# -> cross
# ->  -1   1
# -> 597 969

print(round(100 * mean(cross == 1), 1))          # -> [1] 61.9
[1] 61.9

The rule is long on 61.9% of days and short on the rest. Both branches of ifelse return a number, so there is no flat state and no warm-up gap: the loop seeds both averages on the first close.

The position is again the lagged signal.

cross_pos <- lag(cross)                          # the crossover acted on one row late

print(head(data.frame(fast = round(fast[1:5], 2), slow = round(slow[1:5], 2),
                      signal = cross[1:5], position = cross_pos[1:5]), 5))
   fast  slow signal position
1 75.08 75.08     -1       NA
2 75.26 75.15      1       -1
3 75.46 75.24      1        1
4 75.73 75.36      1        1
5 75.90 75.44      1        1
# ->    fast  slow signal position
# -> 1 75.08 75.08     -1       NA
# -> 2 75.26 75.15      1       -1
# -> 3 75.46 75.24      1        1
# -> 4 75.73 75.36      1        1
# -> 5 75.90 75.44      1        1

Row 1 reads -1 because both averages start at the same close and equal is not greater.

Your turn

Build a signal for CCC that is 1 when the close is above its 100-day average and 0 otherwise, lag it into a position, and print the share of tradable days invested.

msft  <- p |> filter(Ticker == "CCC") |> arrange(Date)   # one ticker, oldest first
mc    <- msft$Close

sma100 <- rollmean(mc, 100, fill = NA, align = "right")   # slower window than AAA's 50
sig    <- ifelse(mc > sma100, 1, 0)                       # 1 above, 0 below
pos    <- lag(sig)                                        # yesterday's signal

print(sum(is.na(sig)))                        # -> [1] 99
print(round(mean(pos, na.rm = TRUE), 3))      # -> [1] 0.754

A 100-day window costs 99 warm-up rows instead of 49, so the rule starts later.

Lesson 24 shows what the lag is worth, by running the same rule with and without it.