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 = signal.shift(1). Why the lag is there is Lesson 30. Here the rule is that what I hold today was decided yesterday.
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 and a three state RSI rule.
Step 1. Six prices
close > sma gives True and False. .astype(float) turns those into 1.0 and 0.0, and shift(1) moves the column down one row.
import numpy as npimport pandas as pdclose = pd.Series([100.0, 102.0, 101.0, 105.0, 104.0, 108.0])sma = close.rolling(3).mean()signal = (close > sma).astype(float) # 1.0 above the average, 0.0 otherwiseposition = signal.shift(1) # today I hold yesterday's signalprint(pd.DataFrame({"close": close, "sma": sma.round(2),"signal": signal, "position": position}))# -> close sma signal position# -> 0 100.0 NaN 0.0 NaN# -> 1 102.0 NaN 0.0 0.0# -> 2 101.0 101.00 0.0 0.0# -> 3 105.0 102.67 1.0 0.0# -> 4 104.0 103.33 1.0 1.0# -> 5 108.0 105.67 1.0 1.0
close sma signal position
0 100.0 NaN 0.0 NaN
1 102.0 NaN 0.0 0.0
2 101.0 101.00 0.0 0.0
3 105.0 102.67 1.0 0.0
4 104.0 103.33 1.0 1.0
5 108.0 105.67 1.0 1.0
Read the signal and position columns side by side. The signal turns on at row 3, the position turns on at row 4.
Rows 0 and 1 have no average yet, and a comparison against NaN is False, so the signal is 0 and nothing is held until the average exists.
I keep the column as float rather than int because shift(1) puts NaN in row 0, and an integer column cannot hold NaN.
Step 2. Price above its 50 day average
prices.csv sits next to this lesson and holds simulated daily closes for four tickers from 2020 to 2025. I take AAPL, put the dates on the index, and run the same two lines on a 50 day average from Lesson 26.
On 17 March the close is above the average, so 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.
Multiplying the position by the return gives what the rule earned each day.
A rule can also produce -1, meaning short. np.where(condition, a, b) picks a where the condition is True and b where it is False, so a fast EMA against a slow EMA gives 1 or -1 with no flat state. It returns a numpy array, so I wrap it back into a Series on the same index.
1.0 969
-1.0 597
Name: count, dtype: int64
long 61.9%
short 38.1%
The two branches of np.where cover every row, so the first row gets a signal too. Both EMAs start at the first close, and equal is not greater, so that row reads -1.
fast slow signal
Date
2020-01-01 75.08 75.08 -1.0
2020-01-02 75.26 75.15 1.0
2020-01-03 75.46 75.24 1.0
Step 4. Three states from RSI
For three states, nest one np.where inside another. Here is the 14 day RSI from Lesson 27, with a rule that goes long below 30, short above 70, and holds nothing in between.
Out of 1566 days the rule is flat on 1431, short on 98 and long on 37. The warm-up rows are in the flat count, since NaN < 30 and NaN > 70 are both False.
The same three states written with mask assignment, which some people find easier to read. Start everything at 0, then overwrite the rows that meet each condition.
The RSI passes 70 on 24 January and the signal reads -1 that day. The short is held on 27 January, and by then the RSI is back under 70, so the position is 0 again the day after.
Your turn
Build a signal for MSFT from prices.csv 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 days the position is 1.