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 = 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 np
import pandas as pd

close = 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 otherwise
position = signal.shift(1)                # today I hold yesterday's signal

print(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.

print((close > sma).head(3))
# -> 0    False
# -> 1    False
# -> 2    False
# -> dtype: bool
0    False
1    False
2    False
dtype: bool

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.

prices = pd.read_csv("prices.csv", parse_dates=["Date"])
aapl   = prices[prices["Ticker"] == "AAPL"].set_index("Date")

close = aapl["Close"]
ret   = close.pct_change()

sma50    = close.rolling(50).mean()
signal   = (close > sma50).astype(float)
position = signal.shift(1)

print(len(close))            # -> 1566

table = 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
1566
            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

print(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.

print((position == 1.0).sum(), "of", position.notna().sum())   # -> 905 of 1565
print(f"{position.mean():.1%}")                                # -> 57.8%
905 of 1565
57.8%

The rule held AAPL on 57.8% of the days.

Step 3. Long and short from a crossover

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.

fast = close.ewm(span=20, adjust=False).mean()
slow = close.ewm(span=50, adjust=False).mean()

signal   = pd.Series(np.where(fast > slow, 1.0, -1.0), index=close.index)
position = signal.shift(1)

table = 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.

print(signal.value_counts())
# ->  1.0    969
# -> -1.0    597
# -> Name: count, dtype: int64

print(f"long  {(signal == 1.0).mean():.1%}")     # -> long  61.9%
print(f"short {(signal == -1.0).mean():.1%}")    # -> short 38.1%
 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.

print(table.head(3)[["fast", "slow", "signal"]])
# ->               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
             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()
gain  = delta.clip(lower=0)
loss  = -delta.clip(upper=0)

avg_gain = gain.ewm(alpha=1 / 14, adjust=False).mean()
avg_loss = loss.ewm(alpha=1 / 14, adjust=False).mean()

rsi = 100 - 100 / (1 + avg_gain / avg_loss)
rsi.iloc[:14] = np.nan               # 14 days of warm-up before the averages mean anything

signal = pd.Series(np.where(rsi < 30, 1.0,
                   np.where(rsi > 70, -1.0, 0.0)), index=close.index)

print(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, which some people find easier to read. Start everything at 0, then overwrite the rows that meet each condition.

alt = pd.Series(0.0, index=close.index)
alt[rsi > 70] = -1.0
alt[rsi < 30] =  1.0

print(alt.equals(signal))     # -> True
True

Now the lag, on the first row where the RSI crosses 70.

position = signal.shift(1)

print(pd.DataFrame({"rsi": rsi.round(1), "signal": signal,
                    "position": position}).head(21).tail(7))
# ->              rsi  signal  position
# -> Date
# -> 2020-01-21  69.4     0.0       0.0
# -> 2020-01-22  69.5     0.0       0.0
# -> 2020-01-23  69.8     0.0       0.0
# -> 2020-01-24  71.5    -1.0       0.0
# -> 2020-01-27  66.9     0.0      -1.0
# -> 2020-01-28  61.7     0.0       0.0
# -> 2020-01-29  67.1     0.0       0.0
             rsi  signal  position
Date                              
2020-01-21  69.4     0.0       0.0
2020-01-22  69.5     0.0       0.0
2020-01-23  69.8     0.0       0.0
2020-01-24  71.5    -1.0       0.0
2020-01-27  66.9     0.0      -1.0
2020-01-28  61.7     0.0       0.0
2020-01-29  67.1     0.0       0.0

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.

import pandas as pd

prices = pd.read_csv("prices.csv", parse_dates=["Date"])
msft   = prices[prices["Ticker"] == "MSFT"].set_index("Date")

close  = msft["Close"]
sma100 = close.rolling(100).mean()

signal   = (close > sma100).astype(float)
position = signal.shift(1)

print((position == 1.0).sum(), "of", position.notna().sum())   # -> 1105 of 1565
print(f"{position.mean():.1%}")                                # -> 70.6%