Building a quantitative value investing strategy


Quantitative Value Strategy

Quantitative value intestment means invest in the stocks that are “cheapest” relative to common measures of business value (like earnings or assets), which focuses on identifying undervalued assets and purchasing them at a price below their intrinsic value (true worth), this strategy is a cornerstone of successful long-term investing.

  • Intrinsic value: the price level that reflects the actual worth or value of the company
  • Margin of safety: the difference between purchase value and the true value

Quantitative value investing relies on various financial ratios to assess the value of potential investments, here below are the commonly used ratios in quantitative value investing.

  • Price-to-Earnings (P/E) Ratio: a lower P/E ratio is often seen as an indicator of undervaluation.
  • Return on Equity (ROE): a higher ROE is generally desirable.
  • Price-to-book (P/B) Ratio: a P/B ratio below 1 can suggest that a stock is undervalued.
  • Price-to-Sales (P/S) Ratio: a lower P/S ratio can indicate undervaluation.
  • Price-to-Cash Flow (P/CF) Ratio: a lower P/CF ratio may indicate that the stock is undervalued. It reflects how much cash the company generates relative to its stock price.
  • Dividend Yield: it measures the annual dividends paid by a company as a percentage of its stock price, therefore a higher dividend yield may be attractive to income-focused investors.
  • Earnings Yield: it is the inverse of the P/E ratio. It’s a measure of how much a company earns in relation to its stock price.
  • Debt-to-Equity (D/E) Ratio: a lower D/E ratio indicates lower financial risk.
  • Current Ratio: it measures a company’s ability to cover its short-term liabilities with its short-term assets. A higher current ratio is often preferred.
  • Free Cash Flow (FCF) Yield: it compares a company’s free cash flow to its market capitalisation. A higher FCF yield can indicate value and financial health.
  • Enterprise Value divided by Earnings Before Interest, Taxes, Depreciation, and Amortization (EV/EBITDA), it takes into account a company’s debt and cash levels in addition to its stock price and relates that value to the firm’s cash profitability. Higher enterprise multiples are expected in high-growth industries and lower multiples in industries with slow growth.
  • Enterprise Value divided by Gross Profit (EV/GP), it is a profitability financial ratio that estimates the enterprise value of a company to its gross profit. Generally, the lower the ratio, the lower is the company’s net worth.

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 value metric (we start with P/E ratio). 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 info using yfinance

import yfinance as yf

def retrieveQuote(ticker):
    stock_dict = yf.Ticker(ticker).info
    stock = pd.DataFrame.from_dict(stock_dict, orient='index')
    stock = stock.reset_index()
    stock = stock.rename(columns={'index': 'Item', 0: 'Value'}) 
    return stock

P/E Ratio

retrieveQuote('AAON')[retrieveQuote('AAON')['Item'] == 'trailingPE']

ItemValue
40trailingPE41.000004

Return on Equity (ROE)

retrieveQuote('AAON')[retrieveQuote('AAON')['Item'] =='returnOnEquity']

ItemValue
122returnOnEquity0.28097

P/B Ratio

retrieveQuote('AAON')[retrieveQuote('AAON')['Item'] == 'priceToBook']

ItemValue
70priceToBook10.003571

P/S Ratio

retrieveQuote('AAON')[retrieveQuote('AAON')['Item'] == 'priceToSalesTrailing12Months']

ItemValue
54priceToSalesTrailing12Months6.116228

Price-to-Cash Flow (P/CF) Ratio

price = yf.Ticker('AAON').history(period='1d')['Close'].item()
price
84.05000305175781
retrieveQuote('AAON')[retrieveQuote('AAON')['Item'] == 'totalCashPerShare']

ItemValue
113totalCashPerShare0.035
print('Price-to-Cash Flow (P/CF) Ratio for AAON is: ', price/retrieveQuote('AAON')[retrieveQuote('AAON')['Item'] == 'totalCashPerShare']['Value'].item())
Price-to-Cash Flow (P/CF) Ratio for AAON is:  2401.4286586216517

EV/EBITDA

retrieveQuote('AAON')[retrieveQuote('AAON')['Item'] == 'enterpriseToEbitda']

ItemValue
87enterpriseToEbitda26.208

Dividend Yield

retrieveQuote('AAON')[retrieveQuote('AAON')['Item'] == 'dividendYield']

ItemValue
35dividendYield0.0038

Debt-to-Equity (D/E) Ratio

retrieveQuote('AAON')[retrieveQuote('AAON')['Item'] == 'debtToEquity']

ItemValue
119debtToEquity8.581

Current Ratio

retrieveQuote('AAON')[retrieveQuote('AAON')['Item'] == 'currentRatio']

ItemValue
117currentRatio3.027

Free Cash Flow (FCF) Yield

retrieveQuote('AAON')[retrieveQuote('AAON')['Item'] == 'freeCashflow']

ItemValue
123freeCashflow16578000
retrieveQuote('AAON')[retrieveQuote('AAON')['Item'] == 'marketCap']

ItemValue
51marketCap6828633600
print("FCF Yield for AAON is: ", retrieveQuote('AAON')[retrieveQuote('AAON')['Item'] == 'freeCashflow']['Value'].item() / retrieveQuote('AAON')[retrieveQuote('AAON')['Item'] == 'marketCap']['Value'].item())
FCF Yield for AAON is:  0.002427718482362269

Retrieve the latest stock price using yfinance

We retrieve the latest day’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 = '1d',
            # 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')

Calculate PE ratio and remove the highest PE ratio 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', 'Price-to-Earnings Ratio', 'Number of Shares to Buy']
final_dataframe = pd.DataFrame(columns = my_columns)
final_dataframe

TickerPricePrice-to-Earnings RatioNumber 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
# Retrieve the PE ratio
for i in range(0,len(tickers)):
    ticker = tickers[i]
    
    trailingPE = retrieveQuote(ticker)[retrieveQuote(ticker)['Item'] == 'trailingPE']
    if trailingPE.shape[0] == 1:
        pe_ratio = retrieveQuote(ticker)[retrieveQuote(ticker)['Item'] == 'trailingPE']['Value'].item()
    else:
        pe_ratio = "NA"
    final_dataframe = final_dataframe.append(
            pd.Series(
            [
                ticker,
                data[ticker]['Close'].item(),
                pe_ratio,
                'NA'
            ],
                index = my_columns),
                ignore_index = True)

final_dataframe.head()

TickerPricePrice-to-Earnings RatioNumber of Shares to Buy
0AAON84.05000341.000004NA
1AAP61.09999817.761627NA
2AAT21.53000125.630955NA
3ABCB45.88000111.794345NA
4ABG214.6199957.467641NA
# remove none PE ratio stocks
final_dataframe = final_dataframe[final_dataframe['Price-to-Earnings Ratio'] != 'NA']
final_dataframe['Price-to-Earnings Ratio'] = final_dataframe['Price-to-Earnings Ratio'].astype(float)
# Remove the high stocks, only keep the lowest 50
final_dataframe.sort_values('Price-to-Earnings Ratio', ascending=True, inplace=True)
final_dataframe = final_dataframe[:50]
final_dataframe.reset_index(drop=True, inplace=True)
final_dataframe.head()

TickerPricePrice-to-Earnings RatioNumber of Shares to Buy
0VTLE46.6100011.393840NA
1BFH36.7799992.495251NA
2AMCX12.4300002.536735NA
3CPE31.2800013.039845NA
4CCOI78.7900013.492465NA
# 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()

TickerPricePrice-to-Earnings RatioNumber of Shares to Buy
0VTLE46.6100011.39384035
1BFH36.7799992.49525145
2AMCX12.4300002.536735134
3CPE31.2800013.03984553
4CCOI78.7900013.49246521

Save the output in Excel using XlsxWriter

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

writer = pd.ExcelWriter('quantitative value.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
        }
    )

float_format = writer.book.add_format(
        {
            'num_format':'0.000',
            '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': ['Price-to-Earnings Ratio', float_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 !