Quant Quickstart V: A Value Strategy

Leo Smigel
June 9, 2020

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 the Intrinio developer sandbox.  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. While all of the examples seen here will using the Intrinio developer sandbox, 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 developer sandbox 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.