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() # average of the last three closessignal = (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 AAA, put the dates on the index, and run the same two lines on a 50-day average from Lesson 26.
prices = pd.read_csv("prices.csv", parse_dates=["Date"]) # Date read as real datesaapl = prices[prices["Ticker"] =="AAA"].set_index("Date") # one ticker, dates on the indexclose = aapl["Close"] # the series every rule below readsret = close.pct_change() # simple return, close to closesma50 = close.rolling(50).mean() # slow 50-day average of the closesignal = (close > sma50).astype(float) # 1.0 above the average, 0.0 belowposition = signal.shift(1) # yesterday's signal is today's holdingprint(len(close)) # -> 1566table = pd.DataFrame({"close": close, "sma50": sma50.round(2),"signal": signal, "position": position})print(table.head(60).tail(8))# -> close sma50 signal position# -> Date# -> 2020-03-13 75.83 76.29 0.0 1.0# -> 2020-03-16 75.72 76.24 0.0 0.0# -> 2020-03-17 76.46 76.22 1.0 0.0# -> 2020-03-18 76.13 76.17 0.0 1.0# -> 2020-03-19 75.07 76.07 0.0 0.0# -> 2020-03-20 75.69 76.02 0.0 0.0# -> 2020-03-23 74.55 75.96 0.0 0.0# -> 2020-03-24 75.92 75.92 0.0 0.0
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.
strategy_return = position * ret # the return counts only on days I am investedprint(pd.DataFrame({"ret": ret.round(6), "position": position,"strategy_return": strategy_return.round(6)}).head(56).tail(6))# -> ret position strategy_return# -> Date# -> 2020-03-11 -0.016424 1.0 -0.016424# -> 2020-03-12 -0.012680 1.0 -0.012680# -> 2020-03-13 -0.035732 1.0 -0.035732# -> 2020-03-16 -0.001451 0.0 -0.000000# -> 2020-03-17 0.009773 0.0 0.000000# -> 2020-03-18 -0.004316 1.0 -0.004316
ret position strategy_return
Date
2020-03-11 -0.016424 1.0 -0.016424
2020-03-12 -0.012680 1.0 -0.012680
2020-03-13 -0.035732 1.0 -0.035732
2020-03-16 -0.001451 0.0 -0.000000
2020-03-17 0.009773 0.0 0.000000
2020-03-18 -0.004316 1.0 -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.
Counting the ones gives the share of days the rule was invested.
A rule can also produce -1, meaning short. A fast EMA against a slow EMA gives 1 or -1 with no flat state, so np.where writes it in one line. It returns a numpy array, so I wrap it back into a Series on the same index.
fast = close.ewm(span=20, adjust=False).mean() # short EMA, turns quicklyslow = close.ewm(span=50, adjust=False).mean() # long EMA, the reference linesignal = pd.Series(np.where(fast > slow, 1.0, -1.0), index=close.index) # long above, short belowposition = signal.shift(1) # the crossover acted on one row latetable = pd.DataFrame({"fast": fast.round(2), "slow": slow.round(2),"signal": signal, "position": position})print(table.head(20).tail(6))# -> fast slow signal position# -> Date# -> 2020-01-21 75.97 75.74 1.0 1.0# -> 2020-01-22 75.78 75.67 1.0 1.0# -> 2020-01-23 75.62 75.61 1.0 1.0# -> 2020-01-24 75.57 75.59 -1.0 1.0# -> 2020-01-27 75.42 75.53 -1.0 -1.0# -> 2020-01-28 75.17 75.42 -1.0 -1.0
fast slow signal position
Date
2020-01-21 75.97 75.74 1.0 1.0
2020-01-22 75.78 75.67 1.0 1.0
2020-01-23 75.62 75.61 1.0 1.0
2020-01-24 75.57 75.59 -1.0 1.0
2020-01-27 75.42 75.53 -1.0 -1.0
2020-01-28 75.17 75.42 -1.0 -1.0
The fast line drops below the slow line on 24 January and the signal flips to -1 that day. The position flips on 27 January, the next row.
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.
delta = close.diff() # change from the previous closegain = delta.clip(lower=0) # up days keep their size, down days become 0loss =-delta.clip(upper=0) # down days as positive sizes, up days 0avg_gain = gain.ewm(alpha=1/14, adjust=False).mean() # Wilder smoothing, 14 daysavg_loss = loss.ewm(alpha=1/14, adjust=False).mean() # the same smoothing on lossesrsi =100-100/ (1+ avg_gain / avg_loss) # 0 to 100, high when gains dominatersi.iloc[:14] = np.nan # 14 days of warm-up before the averages mean anythingsignal = pd.Series(np.where(rsi <30, 1.0, # oversold, go long np.where(rsi >70, -1.0, 0.0)), index=close.index) # overbought short, else flatprint(signal.value_counts())# -> 0.0 1431# -> -1.0 98# -> 1.0 37# -> Name: count, dtype: int64
0.0 1431
-1.0 98
1.0 37
Name: count, dtype: int64
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. Start everything at 0, then overwrite the rows that meet each condition.
alt = pd.Series(0.0, index=close.index) # start every row flatalt[rsi >70] =-1.0# overwrite the overbought rows with a shortalt[rsi <30] =1.0# and the oversold rows with a longprint(alt.equals(signal)) # -> True
True
Now the lag, on the first row where the RSI crosses 70.
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 CCC 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.
TipShow answer
import pandas as pdprices = pd.read_csv("prices.csv", parse_dates=["Date"])msft = prices[prices["Ticker"] =="CCC"].set_index("Date") # one ticker, dates on the indexclose = msft["Close"]sma100 = close.rolling(100).mean() # 100-day averagesignal = (close > sma100).astype(float) # 1.0 above the average, 0.0 belowposition = signal.shift(1) # lag by one rowprint((position ==1.0).sum(), "of", position.notna().sum()) # -> 1105 of 1565print(f"{position.mean():.1%}") # -> 70.6%