Momentum strategies are popular for finding potential stocks ready to move
higher. The criteria below is one such momentum strategy making the rounds.
Today, we will code this using polars.
- close > 1.3 of the 52 week low
- close < .9 of the 52 week high
- close > 50 day simple moving average (SMA)
- 50 day SMA> 150 day SMA
- 150 day SMA > 200 day SMA
- 200 day SMA > 200 day SMA from 1 month ago
- RSI 1 month ago >= 80
Starting with the imports.
Since we only need the last 52 week's worth of data, we can use filter to take
only what we need.
# only need to calculate the last 52 weeks
.filter(
pl.col("Date") > arrow.now().shift(weeks=52).datetime.replace(tzinfo=None)
)
.sort("Date")Next, we need to perform some basic technical analysis on the data. The SMA
can easily be calculated using the rolling_mean() function. The min() and
max() functions can find the 52 week low and high, respectively.
# some basic TA functions
.with_columns(
[
pl.col("Adj Close").rolling_mean(window_size=200).alias("200 SMA"),
pl.col("Adj Close").rolling_mean(window_size=150).alias("150 SMA"),
pl.col("Adj Close").rolling_mean(window_size=50).alias("50 SMA"),
pl.col("High").max().alias("52 week high"),
pl.col("Low").min().alias("52 week low"),
]
)RSI is a bit trickier. It's possible to compute the components of RSI using
only polars expressions and then combine the values. We may try that in future post. For now, we'll be using the RSI function from TAlib.
To calculate the RSI, we use the rolling_map() function, which provides a list of values given a window size. Group_by_rolling() is another function we could use but this function can't account for weekends and holidays (sine we are using daily data).
# For RSI, we use the rolling_map function. The map returns a list
# in the rolling period which is sent to the talib.RSI function.
# Since we only need the last data value from the RSI computation,
# we just grab the last element.
pl.col("Adj Close").rolling_map(
# the timeperiod for RSI is 1 less than the length of data.
lambda d: talib.RSI(d.to_numpy(), timeperiod=len(d))[-1],
window_size=rsi_lookback+1
).alias("RSI")Next, we filter columns no older that 2 and 1 months back, respectively. The
2 months old data is not used in this strategy but I left it here for
completion since there are variations on this strategy using this data.
# get the 2 month old values
.filter(
pl.col("Date") > arrow.now()
.shift(months=2)
.datetime
.replace(tzinfo=None)
)
.with_columns(
[
pl.first("200 SMA").alias("200 SMA 2 month"),
pl.first("150 SMA").alias("150 SMA 2 month"),
]
)
# get the 1 month old values
.filter(
pl.col("Date") > arrow.now()
.shift(months=-1)
.datetime
.replace(tzinfo=None)
)
# add columns for the previous values
.with_columns(
[
pl.first("200 SMA").alias("200 SMA 1 month"),
pl.first("150 SMA").alias("150 SMA 1 month"),
pl.first("50 SMA").alias("50 SMA 1 month"),
pl.first("RSI").alias("RSI 1 month"),
]
)At this point, all of the data needed to perform the calculations are available in the last row so there's no need to work with any older data.
Finally, we add new columns for computing the above conditions. These columns are left as boolean values so that using the select function, they can be passed to the all() python function to give a True/False return if the conditions are met.
# we only need the last column to process the conditions
.last()
# apply the conditions as new columns
.with_columns(
[
(pl.col("Adj Close") > (1.3 * pl.col("52 week low"))).alias("condition 1"),
(pl.col("Adj Close") < (0.9 * pl.col("52 week high"))).alias("condition 2"),
(pl.col("Adj Close") > pl.col("50 SMA")).alias("condition 3"),
(pl.col("50 SMA") > pl.col("150 SMA")).alias("condition 4"),
(pl.col("150 SMA") > pl.col("200 SMA")).alias("condition 5"),
(pl.col("200 SMA") > pl.col("200 SMA 1 month")).alias("condition 6"),
(pl.col("RSI 1 month") >= 80.0).alias("condition 7"),
]
)
# selet the condition columns which are boolean types
.select(
pl.col(pl.Boolean)
)Get the notebook here.