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:

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
- Rank them by
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 |
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 ) |