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 datesaapl <- p |>filter(Ticker =="AAA") |>arrange(Date) # one ticker, oldest firstclose <- aapl$Close # the series every rule below readsdates <- aapl$Datesma50 <-rollmean(close, 50, fill =NA, align ="right") # window ends on the current rowsignal <-ifelse(close > sma50, 1, 0) # 1 above the average, 0 belowposition <-lag(signal) # today I hold yesterday's signaltbl <-data.frame(Date = dates, close = close, sma50 =round(sma50, 2),signal = signal, position = position)print(tbl[55:62, ])
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 stocktradable <-sum(!is.na(position)) # days the rule could act at allprint(c(invested = invested, tradable = tradable))
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 8strategy <- position * ret # earns only on days I am investedprint(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 closefor (i in2: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 quicklyslow <-ema(close, 50) # long EMA, the reference linecross <-ifelse(fast > slow, 1, -1) # long above, short belowprint(table(cross)) # count the days in each state
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 lateprint(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
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.