This Trading Algorithm Landed Me in the Top 10 on Quantopian
This Trading Algorithm Landed Me in the Top 10 on Quantopian
A while back, I achieved a position in the top 10 of Quantopian’s algo trading contest with a trading algorithm I developed. In this article, I’ll delve into the details of its inner workings.
Quantopian was 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.
Quantopian hosts a series of contests known as the “Quantopian Open,” where all members have the opportunity to compete against each other. Participation is open to anyone who joins the site. Utilizing free data sources and tools primarily built in the Python programming language, Quantopian empowers members to engage in algorithmic trading pursuits.
I’ve crafted a trading algorithm that successfully meets the contest criteria outlined by Quantopian. The specific criteria 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 fundamental concept 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:
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
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
)
Despite expectations of complexity, the algorithm I’ve developed is intentionally simplistic. I aimed to keep it as straightforward as possible.