This Trading Algorithm Landed Me in the Top 10 on Quantopian

I have developed a trading algorithm that was able to land me on the top 10 list of Quantopian’s algo trading contest. In this article I describe the details of my algorithm.

Quantopian is a Boston-based company that aims to create a crowd-sourced hedge fund by letting freelance quantitative analysts develop, test, and use trading algorithms to buy and sell securities.

All members can compete against other members in a series of contests called the “Quantopian Open.” Anyone can join the site and enter the contests. Quantopian provides members with free data sources and tools, largely built in the Python programming language.

I have developed a trading algorithm that was able to meet their contest criteria. These are listed below:

Trade liquid stocks: Trade liquid stocks: Contest entries must have 95% or more of their invested capital in stocks in the QTradableStocksUS universe (QTU, for short). This is checked at the end of each trading day by comparing an entry’s end-of-day holdings to the constituent members of the QTradableStocksUS on that day. Contest entries are allowed to have as little as 90% of their invested capital invested in members of the QTU on up to 2% of trading days in the backtest used to check criteria. This is in place to help mitigate the effect of turnover in the QTU definition.

Low position concentration:  Contest entries cannot have more than 5% of their capital invested in any one asset. This is checked at the end of each trading day. Algorithms may exceed this limit and have up to 10% of their capital invested in a particular asset on up to 2% of trading days in the backtest used to check criteria.

Long/short:  Contest entries must not have more than 10% net dollar exposure. This means that the long and short arms of a Contest entry can not be more than 10% different (measured as 10% of the total capital base). For example, if the entry has 100% of its capital invested, neither the sum value of the long investments nor the sum value of the short investments may exceed 55% of the total capital. This is measured at the end of each trading day. Entries may exceed this limit and have up to a 20% net dollar exposure on up to 2% of trading days in the backtest used to check criteria.

Turnover:  Contest entries must have a mean daily turnover between 5%-65% measured over a 63-trading-day rolling window. Turnover is defined as amount of capital traded divided by the total portfolio value. For algorithms that trade once per day, Turnover ranges from 0-200% (200% means the algorithm completely moved its capital from one set of assets to another). Entries are allowed to have as little as 3% rolling mean daily turnover on up to 2% of trading days in the backtest used to check criteria. In addition, entries are allowed to have as much as 80% rolling mean daily turnover on 2% of trading days in the same backtest.

Leverage:  Contest entries must maintain a gross leverage between 0.8x-1.1x. In other words entries must have between 80% and 110% of their capital invested in US equities. The leverage of an algorithm is checked at the end of each trading day. Entries are allowed to have as little as 70% of their capital invested (0.7x leverage) on up to 2% of trading days in the backtest used to check criteria. In addition, entries are allowed to have as much as 120% of their capital invested (1.2x leverage) on up to 2% of trading days in the same backtest. These buffers are meant to provide leniency in cases where trades are canceled, fill prices drift, or other events that can cause leverage to change unexpectedly.

Low beta-to-SPY:  Contest entries must have an absolute beta-to-SPY below 0.3 (low correlation to the market). Beta-to-SPY is measured over a rolling 6-month regression length and is checked at the end of each trading day. The beta at the end of each day must be between -0.3 and 0.3. Contest entries can exceed this limit and have a beta-to-SPY of up to 0.4 on 2% of trading days in the backtest used to check criteria.

Low exposure to Quantopian risk model:  Contest entries must be less than 20% exposed to each of the 11 sectors defined in the Quantopian risk model. Contest entries must also be less than 40% exposed to each of the 5 style factors in the risk model. Exposure to risk factors in the Quantopian risk model is measured as the mean net exposure over a 63-trading-day rolling window at the end of each trading day. Contest entries can exceed these limits on up to 2% of trading days 2 from years before the entry was submitted to today. Entries are allow to have each of sector exposure as high as 25% on 2% of trading days. Additionally, each style exposure can go as high as 50% on 2% of trading days.

Positive returns:  Contest entries must have positive total returns. The return used for the Positive Returns constraint is defined as the portfolio value at the end of the backtest used to check criteria divided by the starting capital ($10M). As with all the criteria, the positive returns criterion is re-checked after each day that an entry remains active in the contest.

The returns can be seen below:

_config.yml

Total Returns: 16.95 % Leverage: 1.00x
Specific Returns: 5.71 % Turnover: 8.30 %
Common Returns: 11.07 % Beta To SPY: -0.07
Sharpe: 0.73 Position Concentration: 0.37 %
Max Drawdown: -16.09 % Net Dollar Exposure: 0.09 %
Volatility: 0.06

The algorithm isn’t as sophisticated as one might think. I tried to keep it as simple as possible. The basic idea is as follows:

  • Trade only US securities.
  • Exclude ADR.
  • Exclude utility and financial stocks.
  • Include only companies with a market cap greater than > 50,000,000 USD
  • Compute each company’s \(value = \frac{1}{(PB \times PE)}\)
  • Rank them by $latex value$ in descending order. That way a company with a high PB and PE ratio will land at the bottom.
  • Short the bottom 300 stocks and go long the top 300 stocks.
  • Rebalance every day.

The complete source code can be seen below:

import quantopian.algorithm as algo
import quantopian.optimize as opt
from quantopian.pipeline import Pipeline
from quantopian.pipeline.factors import SimpleMovingAverage

from quantopian.pipeline.filters import QTradableStocksUS
from quantopian.pipeline.experimental import risk_loading_pipeline

from quantopian.pipeline.data.psychsignal import stocktwits
from quantopian.pipeline.data import Fundamentals

from quantopian.pipeline.data.builtin import USEquityPricing
from quantopian.pipeline.classifiers.fundamentals import Sector
from quantopian.pipeline.factors import CustomFactor, Returns, Latest
import numpy as np
from quantopian.pipeline.data import morningstar

# Constraint Parameters
MAX_GROSS_LEVERAGE = 1.0
TOTAL_POSITIONS = 600

# Here we define the maximum position size that can be held for any
# given stock. If you have a different idea of what these maximum
# sizes should be, feel free to change them. Keep in mind that the
# optimizer needs some leeway in order to operate. Namely, if your
# maximum is too small, the optimizer may be overly-constrained.
MAX_SHORT_POSITION_SIZE = 2.0 / TOTAL_POSITIONS
MAX_LONG_POSITION_SIZE = 2.0 / TOTAL_POSITIONS

def initialize(context):
    algo.attach_pipeline(make_pipeline(), 'long_short_equity_template')
    
    # Attach the pipeline for the risk model factors that we
    # want to neutralize in the optimization step. The 'risk_factors' string is
    # used to retrieve the output of the pipeline in before_trading_start below.
    algo.attach_pipeline(risk_loading_pipeline(), 'risk_factors')
    
    # Schedule our rebalance function
    context.month_counter = 0
    
    algo.schedule_function(func=rebalance,
                           date_rule=algo.date_rules.every_day(),
                           time_rule=algo.time_rules.market_open(hours=0, minutes=30),
                           half_days=True)
        
    # Record our portfolio variables at the end of day
    algo.schedule_function(func=record_vars,
                            date_rule=algo.date_rules.every_day(),
                            time_rule=algo.time_rules.market_close(),
                            half_days=True)

class Value(CustomFactor):
    # Pre-declare inputs and window_length
    inputs = [morningstar.valuation_ratios.pe_ratio,
              morningstar.valuation_ratios.pb_ratio,]
              window_length = 1
              
    # Compute market cap value
    def compute(self, today, assets, out, pe, pb):
        out[:] = 1/(pb[-1]*pe[-1])

def make_pipeline():
    # define our fundamental factor pipeline
    pipe = Pipeline()
    
    # Base universe set to the QTradableStocksUS
    base_universe = QTradableStocksUS()
    
    # Exclude foreign companies (American Depositary Receipts).
    not_depositary = ~Fundamentals.is_depositary_receipt.latest
    
    market_cap = Fundamentals.market_cap.latest
    
    minimum_market_cap = (market_cap > 50000000)
    
    # Exclude utility and financial stocks.
    morningstar_sector = Sector()
    not_utilites_financial_services = ~morningstar_sector.eq(207) & ~morningstar_sector.eq(103)
    
    # Combine filters into a new filter
    securities_to_trade = base_universe & not_depositary & minimum_market_cap & not_utilites_financial_services

    value = Value(mask=securities_to_trade)
    value_rank = value.rank(ascending = False, mask=securities_to_trade)
    combined_rank = value

    # -1 because we want the high numbers to be in the quantile to be shorted and the low numbers in the quantile to be longed
    combined_rank = -1 * combined_rank
    
    # Grab the top and bottom. Remember, the smallest combined_rank wins.
    winners = combined_rank.bottom(TOTAL_POSITIONS//2)
    losers = combined_rank.top(TOTAL_POSITIONS//2)
    
    # Define our universe, screening out anything that isn't in the top or bottom
    universe = securities_to_trade & (losers | winners)
    
    pipe = Pipeline(columns={'combined_rank': combined_rank}, screen=universe)
                    
    return pipe


def before_trading_start(context, data):
    """
    Optional core function called automatically before the open of each market day.
    """
    
    # Call algo.pipeline_output to get the output
    # Note: this is a dataframe where the index is the SIDs for all
    # securities to pass my screen and the columns are the factors
    # added to the pipeline object above
    context.pipeline_data = algo.pipeline_output('long_short_equity_template')
    
    # This dataframe will contain all of our risk loadings
    context.risk_loadings = algo.pipeline_output('risk_factors')


def record_vars(context, data):
    """
    A function scheduled to run every day at market close in order to record
    strategy information.
    """
    longs = shorts = 0
    for position in context.portfolio.positions.itervalues():
        if position.amount > 0:
            longs += 1
        elif position.amount < 0:
            shorts += 1

    # Record our variables.
    record(
       leverage=context.account.leverage,
       long_count=longs,
       short_count=shorts,
       num_positions=len(context.portfolio.positions)
       )


def rebalance(context, data):
    """
    A function scheduled to run once every Month end. It checks if we have a new quarter.
    If that is the case we rebalance the longs and shorts lists.
    """
    trade(context, data)


def trade(context, data):        
    # Retrieve pipeline output
    pipeline_data = context.pipeline_data

    risk_loadings = context.risk_loadings

    # Here we define our objective for the Optimize API. We have
    # selected MaximizeAlpha because we believe our combined factor
    # ranking to be proportional to expected returns. This routine
    # will optimize the expected return of our algorithm, going
    # long on the highest expected return and short on the lowest.
    objective = opt.MaximizeAlpha(pipeline_data.combined_rank)

    # Define the list of constraints
    constraints = []
    # Constrain our maximum gross leverage
    constraints.append(opt.MaxGrossExposure(MAX_GROSS_LEVERAGE))

    # Require our algorithm to remain dollar neutral
    constraints.append(opt.DollarNeutral())

    # Add the RiskModelExposure constraint to make use of the
    # default risk model constraints
    neutralize_risk_factors = opt.experimental.RiskModelExposure(
        risk_model_loadings=risk_loadings,
        version=0
    )
    constraints.append(neutralize_risk_factors)

    
    # With this constraint we enforce that no position can make up
    # greater than MAX_SHORT_POSITION_SIZE on the short side and
    # no greater than MAX_LONG_POSITION_SIZE on the long side. This
    # ensures that we do not overly concentrate our portfolio in
    # one security or a small subset of securities.
    constraints.append(
        opt.PositionConcentration.with_equal_bounds(
            min=-MAX_SHORT_POSITION_SIZE,
            max=MAX_LONG_POSITION_SIZE
        ))

    # Put together all the pieces we defined above by passing
    # them into the algo.order_optimal_portfolio function. This handles
    # all of our ordering logic, assigning appropriate weights
    # to the securities in our universe to maximize our alpha with
    # respect to the given constraints.
    algo.order_optimal_portfolio(
        objective=objective,
        constraints=constraints
    )
Written on August 20, 2021