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()           # average of the last three closes

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 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 dates
aapl   = prices[prices["Ticker"] == "AAA"].set_index("Date")  # one ticker, dates on the index

close = aapl["Close"]                     # the series every rule below reads
ret   = close.pct_change()                # simple return, close to close

sma50    = close.rolling(50).mean()       # slow 50-day average of the close
signal   = (close > sma50).astype(float)  # 1.0 above the average, 0.0 below
position = signal.shift(1)                # yesterday's signal is today's holding

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    # the return counts only on days I am invested

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 AAA on 57.8% of the days.

Step 3. Long and short from a crossover

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 quickly
slow = close.ewm(span=50, adjust=False).mean()   # long EMA, the reference line

signal   = pd.Series(np.where(fast > slow, 1.0, -1.0), index=close.index)  # long above, short below
position = signal.shift(1)                       # the crossover acted on one row late

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()          # change from the previous close
gain  = delta.clip(lower=0)   # up days keep their size, down days become 0
loss  = -delta.clip(upper=0)  # down days as positive sizes, up days 0

avg_gain = gain.ewm(alpha=1 / 14, adjust=False).mean()   # Wilder smoothing, 14 days
avg_loss = loss.ewm(alpha=1 / 14, adjust=False).mean()   # the same smoothing on losses

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

signal = 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 flat

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. Start everything at 0, then overwrite the rows that meet each condition.

alt = pd.Series(0.0, index=close.index)  # start every row flat
alt[rsi > 70] = -1.0                     # overwrite the overbought rows with a short
alt[rsi < 30] =  1.0                     # and the oversold rows with a long

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

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

position = signal.shift(1)    # the three-state signal, acted on one row later

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

import pandas as pd

prices = pd.read_csv("prices.csv", parse_dates=["Date"])
msft   = prices[prices["Ticker"] == "CCC"].set_index("Date")  # one ticker, dates on the index

close  = msft["Close"]
sma100 = close.rolling(100).mean()          # 100-day average

signal   = (close > sma100).astype(float)   # 1.0 above the average, 0.0 below
position = signal.shift(1)                  # lag by one row

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