Magic Formula (Part IV)
Quantopian's Algorithm API is a robust and efficient tool to backtest the Magic Formula Strategy. The algorithm
runs the pipeline
to screen for stocks during each period, creating a factor ranking that can be
used to determine which securities to buy or sell during each rebalance.
Running a 15 Year backtest from 2003 to 2018, we find a small positive Sharpe from our two factor model, with the strategy generating annual returns of 9.88% on a Sharpe of 0.55.
Get the Book: The Little Book that Beats the Market
Parts:
One , Two , Three , Four , Five
Backtesting The Magic Formula Strategy Using Python in Quantopian
The Alphalens
tear sheet showed enough predictive power in the two factor rankings for us to build out
a full backtest. Doing this will require a bit more setup than the work that we've done before in the Jupyter Notebook.
However, since our pipeline has already been created, it shouldn't be hard to add the helper functions we need to do a
proper backtest.
We will leverage the Algorithm API provided by Quantopian to build our backtest. Unlike the research environemt that we have worked with previously, the Algorithm API cannot be implemented through a Jupyter Notebook. Instead, we will implement the strategy in the IDE provided within Quantopian. The API takes a bit of time to get used to, so we'll walk through each function carefully.
First the imports. These should look familiar to you as we used them to create our first pipeline for the screen.
import numpy as np
import pandas as pd
from datetime import timedelta
import quantopian.algorithm as algo
from quantopian.pipeline import Pipeline
from quantopian.pipeline.data.morningstar import Fundamentals
from quantopian.pipeline.data.factset import Fundamentals as FFundamentals
from quantopian.pipeline.classifiers.morningstar import Sector
from quantopian.pipeline.filters import default_us_equity_universe_mask
Initialization
Algorithms are required to include an initialize()
method. This function is called at the start of the
backtest, and contains a context
object that is useful for storing variable states that are passed to
other functions. We'll add three context objects here, one to define the maximimum number of securities in our
portfolio, one to specify how many securities we can buy within each trading period, and an empty dictionary
to help record when we made the trade.
def initialize(context):
context.max_pos = 30
context.stocks_per_period = 3
context.buy_date ={}
The initialize()
function is also where we schedule tasks for our algorithm. There are two functions
that we'll need to run periodically. A function to rebalance
our portfolio each month, and a function
that checks our positions to see if we need to sell
out of any holdings based on the criterion set
forth by the Magic Formula. We'll go into these specific functions further down in this article, but for now, let's
add them to our initialization:
# Rebalance Monthly
algo.schedule_function(
rebalance,
date_rules.month_start(),
time_rules.market_open(hours=1)
)
# Sell Stocks After One Year
algo.schedule_function(
sell,
algo.date_rules.every_day(),
algo.time_rules.market_open(hours=1),
)
Finally, the intialize()
function is also where we create our pipeline and "attach" it to the algorithm.
This registers our pipeline so that our backtest runs through it on each day of the simulation.
algo.attach_pipeline(make_pipeline(), 'pipeline')
Before Trading Starts
Next, we need to create a function that gives us the output of our pipeline each day, as well as a list of stocks
for possible inclusion in our portfolio. We'll do this using the before_trading_start()
function.
This is a function that is executed at the start of each day.
def before_trading_start(context, data):
pipe_out = algo.pipeline_output('pipeline')
# Sort Pipeline List By Our Rank
context.output = pipe_out.sort_values(by='sum_rank', ascending = False)[:30]
# Get Our List of Securities
context.list_of_stocks = context.output.index.tolist()
Rebalance
Now let's go into our rebalance()
function. We call this function once a month, as per our
schedule_function()
defined earlier. Here, we'll iterate through the list of stocks that we generated
from our pipeline, and if there is room in our portfolio to accumulate the position, we will place the order to
add it into our portfolio.
def rebalance(context, data):
now = get_datetime()
curr_round = 0
for stock in context.list_of_stocks:
if stock not in context.portfolio.positions and len(context.portfolio.positions) < context.max_pos and curr_round < context.stocks_per_period:
if data.can_trade(stock):
order_target_percent(stock, 1.0/context.max_pos)
context.buy_date[stock] = now
curr_round+=1
Selling Stocks
Finally, we'll need to sell out of our positions after a year. In keeping with the tax considerations introduced by Greenblatt, we will sell our losers on day 345 (or later based on trading days), and exit our winners on day 375, to capture long term capital gains on those winners.
In very rare instances, a position will show up in context.portfolio.positions
but not in our
context.buy_date
dictionary. This may occur for a number of reasons. As an example, a company
may spin off a business segment into its own publicly traded entity, resulting in an extra ticker in
context.portfolio.positions
that cannot be found in context.buy_date
. In these instances,
we add this new symbol into context.buy_date
, even though this restarts the clock for the one
year holding period of this new security. An alternative would be to liquidate the new symbol, but we feel that
since this occurs infrequently, either move would not drastically alter the performance of our backtest.
def sell(context, data):
now = get_datetime()
for stock in context.portfolio.positions:
if stock in context.buy_date:
if data.can_trade(stock):
if context.portfolio.positions[stock].cost_basis < data.current(stock,'price'):
if now - context.buy_date[stock] > timedelta(days=84):
order_target_percent(stock,0)
del context.buy_date[stock]
elif context.portfolio.positions[stock].cost_basis >= data.current(stock,'price'):
if now - context.buy_date[stock] > timedelta(days=84):
del context.buy_date[stock]
order_target_percent(stock,0)
else:
context.buy_date[stock] = get_datetime()
Putting It All Together
And that's it. All the components are in place for our backtest. The final code in its entirety is presented here:
import numpy as np
import pandas as pd
from datetime import timedelta
import quantopian.algorithm as algo
from quantopian.pipeline import Pipeline
from quantopian.pipeline.data.morningstar import Fundamentals
from quantopian.pipeline.data.factset import Fundamentals as FFundamentals
from quantopian.pipeline.classifiers.morningstar import Sector
from quantopian.pipeline.filters import default_us_equity_universe_mask
def initialize(context):
context.max_pos = 30
context.stocks_per_period = 3
context.buy_date ={}
# Rebalance Monthly
algo.schedule_function(
rebalance,
date_rules.month_start(),
time_rules.market_open(hours=1)
)
# Sell Stocks After One Year
algo.schedule_function(
sell,
algo.date_rules.every_day(),
algo.time_rules.market_open(hours=1),
)
# Create and Attach Pipeline
algo.attach_pipeline(make_pipeline(), 'pipeline')
def make_pipeline():
# Step One: Limiting to MarketCap Over 200MM
market_cap_filter = Fundamentals.market_cap.latest > 200000000
# Step Two: Filtering out Financials and Utilities
sector = Sector()
sector_filter = sector.element_of([
101, #Materials
102, #Consumer Discretionary
#103, #Financial
104, #Real Estate
205, #Consumer Staples
206, #Healthcare
#207, #Utilities
308, #Telecoms
309, #Energy
310, #Industrials
311, #Technology
])
# Step Three: Filtering out ADRs
tradable_filter = default_us_equity_universe_mask()
# Combining Filters Into a Screen
universe = market_cap_filter & sector_filter & tradable_filter
# Step Four: Determine Earning's Yield EBIT/EV
ebit_ltm = FFundamentals.ebit_oper_ltm.latest
ev = Fundamentals.enterprise_value.latest
earnings_yield = ebit_ltm/ev
# Step Five: Determine Return on Capital
ppe_net = FFundamentals.ppe_net.latest
# Net Working Capital
total_assets = Fundamentals.total_assets.latest
current_liabilities = Fundamentals.current_liabilities.latest
current_notes_payable = Fundamentals.current_notes_payable.latest
net_working_capital = (total_assets - (current_liabilities - current_notes_payable))
# Excess Cash As a Percentage of Sales
cash = Fundamentals.cash.latest
sales_ltm = FFundamentals.sales_ltm.latest
excess_cash = max((cash-(sales_ltm*0.03)),0)
goodwill_and_intangibles = Fundamentals.goodwill_and_other_intangible_assets.latest
roc = ebit_ltm / (net_working_capital + ppe_net - goodwill_and_intangibles - excess_cash)
# Step Six: Rank Companies
ey_rank = earnings_yield.rank(ascending=True)
roc_rank = roc.rank(ascending=True)
sum_rank = (ey_rank + roc_rank).rank()
return Pipeline(
columns={
'symbol': Fundamentals.primary_symbol.latest,
'sum_rank': sum_rank,
},
screen = universe,
)
def before_trading_start(context, data):
pipe_out = algo.pipeline_output('pipeline')
# Sort Pipeline List By Our Rank
context.output = pipe_out.sort_values(by='sum_rank', ascending = False)[:30]
# Get Our List of Securities
context.list_of_stocks = context.output.index.tolist()
def rebalance(context, data):
now = get_datetime()
curr_round = 0
for stock in context.list_of_stocks:
if stock not in context.portfolio.positions and len(context.portfolio.positions) < context.max_pos and curr_round < context.stocks_per_period:
if data.can_trade(stock):
order_target_percent(stock, 1.0/context.max_pos)
context.buy_date[stock] = now
log.info('Buying Stock ' + str(stock))
curr_round+=1
def sell(context, data):
now = get_datetime()
for stock in context.portfolio.positions:
if stock in context.buy_date:
if data.can_trade(stock):
if context.portfolio.positions[stock].cost_basis < data.current(stock,'price'):
if now - context.buy_date[stock] > timedelta(days=345):
log.info('Selling Loser ' + str(stock))
order_target_percent(stock,0)
del context.buy_date[stock]
elif context.portfolio.positions[stock].cost_basis >= data.current(stock,'price'):
if now - context.buy_date[stock] > timedelta(days=375):
log.info('Selling Winner ' + str(stock))
del context.buy_date[stock]
order_target_percent(stock,0)
else:
context.buy_date[stock] = get_datetime()
Output
One of our favorite things about Quantopian's Algorithm API is that it offers tearsheets on the backtests that can be shared. To see our results in your own Jupyter Notebook (within Quantopian), run the following code:
bt = get_backtest('5f29085c83f92346e57b415a')
bt.create_full_tear_sheet()
The key elements of our backtest is below:
Annual Returns | 9.883% |
---|---|
Cumulative Returns | 310.32% |
Annual Volatility | 21.45% |
Sharpe Ratio | 0.55 |
Sortino Ratio | 0.77 |
Max Drawdown | -55.61% |
We see a slight positive Sharpe from the output of our backtest, but our cumulative returns in the 15 years studied matches close to that of the S&P 500. Additionally, there was a Max Drawdown of 55.61%, so we do see some significant and prolonged periods of underperformance from this strategy. We do see that, while there were some strong gains post Great Recession, the strategy has had a number of weaker returns relative to the market.
Conclusion
Since Greenblatt's book was first published, value investing has lost some of its luster due to many years of underperformance against the broader market. As Greenblatt himself puts it:
"First, buying good companies at bargain prices makes sense. On average, this is what the magic formula does. Second, it can take Mr. Market several years to recognize a bargain. Therefore, the magic formula strategy requires patience."
It certain seems like it's been more than a couple years. And yet, the Magic Formula lives on as one of the more popular investment strategies. While the theory behind the formula is indeed simple, very small details in how we decide what is considered "good" and "cheap" can make a significant impact to your stock universe. Some of the book's suggestions, like removing stocks with a P/E fo 5 of below, seem to conflict with the stock list generated by Greenblatt's website. That said, our exercise was as much about replicating the book's output as it was about demonstrating a quant workflow, and to that we hope you have found value in this experience.
In the fifth and final installment of this strategy discussion, we'll add some concluding remarks on this strategy, and compare our results to Greenblatt's own performance in running Gotham.