Building a quantitative momentum investing strategy


Quantitative Momentum Strategy

Quantitative Momentum is an investment strategy which selects for investment the stocks whose price appreciated the most during a period (usually the recent year, ignoring the most recent month).

The goal of this section is trying to create an investing strategy following the instruction on https://github.com/nickmccullum/algorithmic-trading-python/tree/master, with this strategy it selects the 50 stocks in S&P 600 list with the highest price momentum. From there, the trades with an equal-weight portfolio of these 50 stocks will be calculated.

Load the list of S&P 600 companies from Wikepedia

The S&P 600 is an index of small-cap company stocks created by Standard & Poor’s, selected by a committee based on recent profitability and other factors.

Currently there are 3 S&P Small Cap 600 ETFs traded on the U.S. markets, the largest one is iShares S&P Small-Cap 600 Value ETF IJS with around 7.09 Billon USD in assets.

# Get the list of S&P 600 stocks from Wikepedia
import pandas as pd

def load_data(url):
    html = pd.read_html(url, header=0)
    return html

# Load the list of S&P 600 companies
url = 'https://en.wikipedia.org/wiki/List_of_S%26P_600_companies'
df = load_data(url)[0]
df.head()

SymbolCompanyGICS SectorGICS Sub-IndustryHeadquarters LocationSEC filingsCIK
0AAONAAON, Inc.IndustrialsBuilding ProductsTulsa, Oklahomaview824142
1AAPAdvance Auto Parts, Inc.Consumer DiscretionaryAutomotive RetailRaleigh, North Carolinaview1158449
2AATAmerican Assets TrustReal EstateDiversified REITsSan Diego, Californiaview1500217
3ABCBAmeris BancorpFinancialsRegional BanksAtlanta, Georgiaview351569
4ABGAsbury Automotive GroupConsumer DiscretionaryAutomotive RetailDuluth, Georgiaview1144980

Retrieve the latest stock price using yfinance

We retrieve the latest year’s stock price using yfinance.

import yfinance as yf
import warnings
warnings.filterwarnings('ignore')

data = yf.download(
            # tickers list
            tickers = list(df['Symbol']),
            # valid periods: 1d, 5d, 1mo, 3mo, 6mo, 1y, 5y, 10y, ytd, max
            period = '1y',
            # valid intervals: 1m, 2m, 5m, 15m, 15m, 30m, 60m, 90m, 1h, 1d, 5d, 1wk, 1mo, 3mo
            interval = '1d',
            # group by ticker
            group_by = 'ticker',
            # adjust all OHLC automatically
            auto_adjust = True,
            # download pre/post regular market hours data
            prepost = True,
            # use threads for mass downloading
            threads = True,
            # proxy URL scheme when downloading
            proxy = None
            )
[*********************100%%**********************]  602 of 602 completed

2 Failed downloads:
['MOG.A', 'CWEN.A']: Exception('%ticker%: No data found, symbol may be delisted')
data['AAON'].head()

PriceOpenHighLowCloseVolume
Date
2023-02-2452.80496354.09014452.44061053.970898367500
2023-02-2753.97090255.03746853.28194053.984154653700
2023-02-2857.63432462.53655556.30939860.2576791987800
2023-03-0160.08543760.68165259.51571859.999317566400
2023-03-0259.79394960.70815159.43621860.522659792450

Calculate 1-year return and remove the lowest momentum stocks

# Get the available tickers
tickers_unavailable = ['MOG.A', 'CWEN.A']
tickers = [ticker for ticker in list(df['Symbol']) if ticker not in tickers_unavailable]
print('Number of available tickers: ', len(tickers))
Number of available tickers:  600
# Create the format of the dataframe that stores the stock price, market cap and number of shares to buy
my_columns = ['Ticker', 'Price', 'One-Year Price Return', 'Number of Shares to Buy']
final_dataframe = pd.DataFrame(columns = my_columns)
final_dataframe

TickerPriceOne-Year Price ReturnNumber of Shares to Buy
# Input the value of the portforlio, i.e. example here is 1M USD

portfolio_size = input("Enter the value of your portfolio:")

try:
    val = float(portfolio_size)
except ValueError:
    print("That's not a number! \n Try again:")
    portfolio_size = input("Enter the value of your portfolio:")
Enter the value of your portfolio:1000000
# Calculate the 1-year return
for i in range(0,len(tickers)):
    ticker = tickers[i]
    final_dataframe = final_dataframe.append(
            pd.Series(
            [
                ticker,
                data[ticker]['Close'].to_list()[-1],
                data[ticker]['Close'].to_list()[-1] - data[ticker]['Close'].to_list()[0],
                'NA'
            ],
                index = my_columns),
                ignore_index = True)

final_dataframe.head()

TickerPriceOne-Year Price ReturnNumber of Shares to Buy
0AAON84.05000330.079105NA
1AAP61.099998-74.325050NA
2AAT21.530001-2.397324NA
3ABCB45.880001-1.409645NA
4ABG214.619995-6.620010NA
# Remove the low-momentum stocks, only keep the top 50
final_dataframe.sort_values('One-Year Price Return', ascending=False, inplace=True)
final_dataframe = final_dataframe[:50]
final_dataframe.reset_index(drop=True, inplace=True)
final_dataframe.head()

TickerPriceOne-Year Price ReturnNumber of Shares to Buy
0AMR389.880005234.812805NA
1IBP234.089996122.137169NA
2POWL161.710007118.683826NA
3WDFC267.00000097.170700NA
4ANF122.82000093.830000NA
# Calculate the number of shares to buy
import math
position_size = float(portfolio_size) / len(tickers)
for i in range(0,len(final_dataframe['Ticker'])):
    final_dataframe.loc[i, 'Number of Shares to Buy'] = math.floor(position_size / final_dataframe['Price'][i])
final_dataframe.head()

TickerPriceOne-Year Price ReturnNumber of Shares to Buy
0AMR389.880005234.8128054
1IBP234.089996122.1371697
2POWL161.710007118.68382610
3WDFC267.00000097.1707006
4ANF122.82000093.83000013

Save the output in Excel using XlsxWriter

# Initiate the XlsxWriter object, save the output in xlsx format.
import xlsxwriter

writer = pd.ExcelWriter('quantitative momentum.xlsx', engine = 'xlsxwriter')
final_dataframe.to_excel(writer, 'Recommended Trades', index = False)

Create the formats:

  • String format for tickers
  • $xx.xx format for stock prices
  • $xx,xxx for market capitalization
  • Integer format for the number of shares to buy
background_color = '#0a0a23'
font_color = '#ffffff'

string_format = writer.book.add_format(
        {
            'font_color': font_color,
            'bg_color': background_color,
            'border': 1
        }
    )

dollar_format = writer.book.add_format(
        {
            'num_format':'$0.00',
            'font_color': font_color,
            'bg_color': background_color,
            'border': 1
        }
    )

capital_format = writer.book.add_format(
        {
            'num_format':'$#,##0',
            'font_color': font_color,
            'bg_color': background_color,
            'border': 1
        }
    )

integer_format = writer.book.add_format(
        {
            'num_format':'0',
            'font_color': font_color,
            'bg_color': background_color,
            'border': 1
        }
    )

percent_format = writer.book.add_format(
        {
            'num_format':'0.0%',
            'font_color': font_color,
            'bg_color': background_color,
            'border': 1
        }
    )
# Apply the formats to the columns of the output file
column_formats = {
    'A': ['Ticker', string_format],
    'B': ['Stock Price', dollar_format],
    'C': ['One-Year Price Return', dollar_format],
    'D': ['Number of Shares to Buy', integer_format],
}

for column in column_formats.keys():
    writer.sheets['Recommended Trades'].set_column(f'{column}:{column}', 18, column_formats[column][1])
    writer.sheets['Recommended Trades'].write(f'{column}1', column_formats[column][0], string_format)
# Save the output
writer.save()

Author: wenvenn
Reprint policy: All articles in this blog are used except for special statements CC BY 4.0 reprint policy. If reproduced, please indicate source wenvenn !