New to the Quant Quickstart series? Read parts one, two, three, and four.
Hello, world! It's Leo Smigel and I'm back with the fifth installment of the Quant Quickstart series where I help readers get started with Algorithmic Trading.
In this post, we're going to be building on what we've learned so far to create a value "factor" strategy in Backtrader using data from Intrinio. More specifically, we're going to be ranking stocks by their price-to-book ratio and then selecting the top N stocks that are the cheapest based on this ratio.
We're going to first grab the data directly from Intrinio, and then load it into Backtrader. There's a fair amount of code to cover, so I'll only go in-depth on code we haven't already seen in previous articles. Let's get to it!
Get Data Directly from Intrinio
Previously, we've loaded our data into CSV files. From now on, we'll be grabbing the data directly from Intrinio. You'll want to get a subscription once you're ready to create your own strategies.
We start with the usual imports, select our start and end dates, and enter our API key.
import intrinio_sdk
import pandas as pd
import backtrader as bt
start_date = '2017-01-01'
end_date = '2017-12-31'
apikey = 'your_api_key'
We grab the data like we did in Quant Quickstart II. Only this time, we add the data to a Pandas dataframe instead of saving it to a CSV file.
stocks = [
'aapl', 'axp', 'ba', 'cat', 'csco',
'cvx', 'dis','ge', 'gs',
'hd', 'ibm', 'intc', 'jnj', 'jpm',
'ko', 'mcd', 'mmm', 'mrk', 'msft',
'nke', 'pfe', 'pg', 'trv', 'unh',
'utx', 'v', 'vz', 'wmt', 'xom'
]
intrinio_sdk.apiclient().configuration.api_key['api_key'] = apikey,
security_api = intrinio_sdk.securityapi()
company_api = intrinio_sdk.companyapi()
fundamental_api = intrinio_sdk.fundamentalsapi()
securities_prices = pd.dataframe()
securities_fundamentals = pd.dataframe()
for stock in stocks:
# loop through the api response creating a list in the format we need.
api_response = security_api.get_security_stock_prices(stock,
start_date=start_date,
end_date=end_date)
prices = []
for row in api_response.stock_prices_dict:
t = stock
d = row['date']
o = row['adj_open']
h = row['adj_high']
l = row['adj_low']
c = row['adj_close']
v = row['adj_volume']
prices.append([t, d, o, h, l, c, v])
security_prices = pd.dataframe(prices, columns=['ticker', 'date', 'open', 'high', 'low', 'close', 'volume'])
security_prices is a Pandas dataframe of stock prices. Let's do the same for our fundamental, price-to-book, data:
quarters = ['q1', 'q2', 'q3', 'q4']
fundamentals = []
for quarter in quarters:
api_response = company_api.lookup_company_fundamental(stock,
statement_code='calculations',
fiscal_year=2017,
fiscal_period=quarter)
funid = api_response.to_dict()['id']
fun_api_response = fundamental_api.get_fundamental_standardized_financials(funid)
for tags in fun_api_response.standardized_financials_dict:
if tags['data_tag']['tag'] == 'pricetobook':
pb = tags['value']
date = fun_api_response.fundamental_dict['start_date']
fundamentals.append([stock, date, pb])
security_fundamentals = pd.dataframe(fundamentals, columns=['ticker', 'date', 'pb'])
securities_fundamentals = securities_fundamentals.append(security_fundamentals)
securities_prices = securities_prices.append(security_prices)
Now we're going to do something new. Let's merge the price and fundamental data. We want our data to be grouped by ticker and then by date. With this in mind, we'll sort both the price data and the fundamental data by ticker and date, and then we'll merge the two dataframes while filling the missing fundamental data as our price data is daily and our fundamental data is less frequent.
securities_prices = securities_prices.set_index(['ticker', 'date']).sort_index()
securities_fundamentals = securities_fundamentals.set_index(['ticker', 'date']).sort_index()
securities_prices = securities_prices.merge(securities_fundamentals, on=['ticker','date'], how='left').ffill().bfill()
The last line of code merges the data on the daily index, and forward and backfills missing data. For example, let's assume we have the price-to-book ratio reported on 1/1/2020. For 1/2/2020 until the price-to-book ratio is reported again will be blank. We can take the value from 1/1/2020 and fill it forward until the next value. Again, pandas is amazing for data manipulation.
After all that downloading and data manipulation, here's what the final product looks like.
open high low close volume pb
ticker date
aapl 2017-08-09 152.649084 154.575648 152.505310 154.374365 26131530.0 159.5764
2017-08-10 153.864371 153.960596 148.793294 149.457249 40804273.0 159.5764
2017-08-11 150.688933 152.587268 150.178939 151.535717 26257096.0 159.5764
2017-08-14 153.306264 154.162669 152.757779 153.816258 22122734.0 159.5764
2017-08-15 154.595684 156.072743 154.095312 155.500202 29465487.0 159.5764
... ... ... ... ... ... ...
xom 2017-12-22 74.331836 74.455900 74.083709 74.411591 10161447.0 2.8085
2017-12-26 74.402729 74.757197 74.349559 74.420453 4777216.0 2.8085
2017-12-27 74.429315 74.526793 74.207772 74.349559 7000612.0 2.8085
2017-12-28 74.420453 74.482485 74.260942 74.455900 7495254.0 2.8085
2017-12-29 74.438176 74.615410 74.119155 74.119155 8523411.0 2.8085
Create the Strategy in Backtrader
With the data in the format we need, let's move on to Backtrader.
We create a class called PandasDataCustom that inherits from backtrader.feeds.PandasData. When we inherit from a parent class, our child class gets all of the functionality the parent has automagically. That's why we only need to add the additional functionality, which in our case is the price-to-book data.
class pandasdatacustom(bt.feeds.pandasdata):
lines = ('pb',)
params = (
('pb', -1),
)
Now let's create our strategy. Again, if any of this is unfamiliar, please review the previous posts.
class st(bt.strategy):
params = dict(
targetnum=5,
targetpct=0.2,
weekdays=[5],
weekcarry=true,
when=bt.timer.session_start
)
def __init__(self):
self.inds = {}
for stock in self.datas:
self.inds[stock] = {}
self.inds[stock]['pb'] = stock.pb
self.add_timer(
when=self.p.when,
weekdays=self.p.weekdays,
weekcarry=self.p.weekcarry
)
def notify_timer(self, timer, when, *args, **kwargs):
self.rebalance(
We start by adding a few params. We are trading every fifth weekday (Fridays) and we create a dictionary to store our price-to-book indicator. We also add a timer that will call rebalance weekly. Again, all stuff we've seen before. Now let's rebalance.
def rebalance(self):
# rank by lowest price-to-book value
ranks = sorted(self.datas, key=lambda d: self.inds[d]['pb'][0])
ranks = ranks[:self.p.targetnum]
# get existing positions
posdata = [d for d, pos in self.getpositions().items() if pos]
# close positions if they are no longer in top rank
for d in (d for d in posdata if d not in ranks):
print(f"closing: {d.p.name}")
self.close(d)
# rebalance positions already there
for d in (d for d in posdata if d in ranks):
print(f"rebalancing: {d.p.name}")
self.order_target_percent(d, target=self.p.targetpct)
# buy new positions
for d in ranks:
print(f"buying: {d.p.name}")
self.order_target_percent(d, target=self.p.targetpct)
All of the magic occurs within rebalance. We rank the stocks using sorted using an anonymous lambda function which returns a list sorted by price-to-book.
Next, we get the existing positions using a Python listcomp. Notice how we are both unpacking pos and filtering by it.
We then close positions if they are no longer in the top rank. The syntax is very similar to the list comprehension (listcomp) above except instead of returning a list, we return a generator expression (genexp), which allows us to use the for d.
Finally, we buy the new positions. We don't have to keep track of the share count to purchase as we're using self.order_target_percent. Also, the above could be made more efficient by using dictionaries, but this will suffice for now.
With the strategy out of the way, we're greeted with familiar code where we instantiate cerebro, add the data and strategy, and run and plot the results.
# initialize cerebro
cerebro = bt.cerebro()
print(securities_prices)
# add data
for ticker, data in securities_prices.groupby(level=0):
print(f"adding ticker {ticker}")
d = pandasdatacustom(dataname=data.droplevel(level=0),
name=ticker,
plot=false)
cerebro.adddata(d)
# add strategy
cerebro.addstrategy(st)
# run the strategy
cerebro.run()
# plot results
cerebro.plot(
More code than usual, but at this point, you should be able to understand it. If not, go back to the previous articles and make sure you catch up. Also, as always, you can download the accompanying code from GitHub.
New to Intrinio? Visit intrinio.com to learn more.