A simple market making algo with a net Sharpe ratio > 7 year-to-date

Below is a simple market making algorithm:

• It takes the 300 least liquid stocks from the QTradableStocksUS universe
• Every 10 minutes and on each of those stocks, it places a bid at price-0.01 and an offer at price +0.01.

With contest commissions and slippage (but 1M\$), this algo supposedly generated a Sharpe ratio of 7.29 YTD.

115
Backtest from to with initial capital
Total Returns
--
Alpha
--
Beta
--
Sharpe
--
Sortino
--
Max Drawdown
--
Benchmark Returns
--
Volatility
--
 Returns 1 Month 3 Month 6 Month 12 Month
 Alpha 1 Month 3 Month 6 Month 12 Month
 Beta 1 Month 3 Month 6 Month 12 Month
 Sharpe 1 Month 3 Month 6 Month 12 Month
 Sortino 1 Month 3 Month 6 Month 12 Month
 Volatility 1 Month 3 Month 6 Month 12 Month
 Max Drawdown 1 Month 3 Month 6 Month 12 Month
from quantopian.pipeline import Pipeline, CustomFilter
from quantopian.algorithm import attach_pipeline, pipeline_output
from quantopian.pipeline.factors import Latest
from quantopian.pipeline.data.builtin import USEquityPricing
from quantopian.pipeline.factors import AverageDollarVolume
import numpy as np

def initialize(context):
pipe = Pipeline()
#pricing = USEquityPricing.close.latest
dollar_volume = AverageDollarVolume(window_length=30)
attach_pipeline(pipe, 'pipe')
schedule_function(flush_portfolio, date_rules.month_start(), time_rules.market_close(minutes=50))

for i in range(1,300,10):
schedule_function(limit_orders, date_rules.every_day(), time_rules.market_open(minutes=i))
schedule_function(flush_orders, date_rules.every_day(), time_rules.market_open(minutes=(i+9)))

def limit_orders(context,data):
currprice = data.current(context.output.index,'price')
nstocks = max(10,len(context.output))
for stock in currprice.iteritems():
try:
order_target_percent(stock[0],1./nstocks,style = LimitOrder(stock[1]-0.01))
order_target_percent(stock[0],-1./nstocks,style = LimitOrder(stock[1]+0.01))
except:
pass

def flush_orders(context,data):
for stock, orders in get_open_orders().iteritems():
for order in orders:
cancel_order(order)

def flush_portfolio(context,data):
for stock in context.portfolio.positions:
order_target_percent(stock,0)


There was a runtime error.
8 responses

I agree with your initial idea that this performance are due to the bug in the limit orders.

Here is a backtest with a patched Slippage model. It is not perfect but it less optimistic regarding filled price of limit orders than current zipline implementation.

EDIT:
The only change I made to the original zipline slippage model is relative to the filled price of a limit order: when the limit price is crossed zipline fills the order at the new price (close price of the current minute bar) while in reality and in this patched Slippage model the fill price is exactly the limit price.

18
Backtest from to with initial capital
Total Returns
--
Alpha
--
Beta
--
Sharpe
--
Sortino
--
Max Drawdown
--
Benchmark Returns
--
Volatility
--
 Returns 1 Month 3 Month 6 Month 12 Month
 Alpha 1 Month 3 Month 6 Month 12 Month
 Beta 1 Month 3 Month 6 Month 12 Month
 Sharpe 1 Month 3 Month 6 Month 12 Month
 Sortino 1 Month 3 Month 6 Month 12 Month
 Volatility 1 Month 3 Month 6 Month 12 Month
 Max Drawdown 1 Month 3 Month 6 Month 12 Month
from quantopian.pipeline import Pipeline, CustomFilter
from quantopian.algorithm import attach_pipeline, pipeline_output
from quantopian.pipeline.factors import Latest
from quantopian.pipeline.data.builtin import USEquityPricing
from quantopian.pipeline.factors import AverageDollarVolume
import numpy as np
import math

DEFAULT_VOLUME_SLIPPAGE_BAR_LIMIT = 0.025

class LiquidityExceeded(Exception):
pass

class myVolumeShareSlippage(slippage.SlippageModel):
"""Model slippage as a function of the volume of shares traded.
"""

def __init__(self, volume_limit=DEFAULT_VOLUME_SLIPPAGE_BAR_LIMIT,
price_impact=0.1):

self.volume_limit = volume_limit
self.price_impact = price_impact

slippage.SlippageModel.__init__(self)

def process_order(self, data, order):
volume = data.current(order.asset, "volume")

max_volume = self.volume_limit * volume

# price impact accounts for the total volume of transactions
# created against the current minute bar
remaining_volume = max_volume - self.volume_for_bar
if remaining_volume < 1:
# we can't fill any more transactions
#raise LiquidityExceeded()
return None, None

# the current order amount will be the min of the
# volume available in the bar or the open amount.
cur_volume = int(min(remaining_volume, abs(order.open_amount)))

if cur_volume < 1:
return None, None

# tally the current amount into our total amount ordered.
# total amount will be used to calculate price impact
total_volume = self.volume_for_bar + cur_volume

volume_share = min(total_volume / volume,
self.volume_limit)

price = data.current(order.asset, "close")

simulated_impact = volume_share ** 2 \
* math.copysign(self.price_impact, order.direction) \
* price
impacted_price = price + simulated_impact

if order.limit:
# this is tricky! if an order with a limit price has reached
# the limit price, we will try to fill the order. do not fill
# these shares if the impacted price is worse than the limit
# price. return early to avoid creating the transaction.

# buy order is worse if the impacted price is greater than
# the limit price. sell order is worse if the impacted price
# is less than the limit price
if (order.direction > 0 and impacted_price > order.limit) or \
(order.direction < 0 and impacted_price < order.limit):
return None, None

# For "non-marketable" limit orders (limit price has been crossed)
# the final price must be the limit price.
# To disinguish between marketable and non-marketable limit
# order we can use the following check:
# if both open and close price are below/above (buy/sell)
# the limit price the order is markettable
# if open price is above (or below for sell) limit price
# and close price is below (or above for sell) limit price,
# then the order is non-marketable

open_price = data.current(order.asset, "open")
# Note: no need to check for close price if we are here
non_marketable = (order.direction > 0 and open_price > order.limit) or \
(order.direction < 0 and open_price < order.limit)

if non_marketable:
impacted_price = order.limit

return (
impacted_price,
math.copysign(cur_volume, order.direction)
)

def initialize(context):
set_slippage(myVolumeShareSlippage(volume_limit=DEFAULT_VOLUME_SLIPPAGE_BAR_LIMIT, price_impact=0.1))

pipe = Pipeline()
#pricing = USEquityPricing.close.latest
dollar_volume = AverageDollarVolume(window_length=30)
attach_pipeline(pipe, 'pipe')
schedule_function(flush_portfolio, date_rules.month_start(), time_rules.market_close(minutes=50))

for i in range(1,300,10):
schedule_function(limit_orders, date_rules.every_day(), time_rules.market_open(minutes=i))
schedule_function(flush_orders, date_rules.every_day(), time_rules.market_open(minutes=(i+9)))

def limit_orders(context,data):
currprice = data.current(context.output.index,'price')
nstocks = max(10,len(context.output))
for stock in currprice.iteritems():
try:
order_target_percent(stock[0],1./nstocks,style = LimitOrder(stock[1]-0.01))
order_target_percent(stock[0],-1./nstocks,style = LimitOrder(stock[1]+0.01))
except:
pass

def flush_orders(context,data):
for stock, orders in get_open_orders().iteritems():
for order in orders:
cancel_order(order)

def flush_portfolio(context,data):
for stock in context.portfolio.positions:
order_target_percent(stock,0)


There was a runtime error.

Indeed, the performance was clearly unrealistic, and very likely due to this bug.

I removed my initial comment on this, as it could be interpreted as a gratuitous attack on the performance of all algos that use limit orders.

Thanks for your custom slippage model code, which clarifies the matter.

Some detail just tracking GSM orders (lowest PnL in first 5 days)

The non marketable limit orders still haven't been fixed...?

Also, does the OP know this holds both long and short?

I assume the algo is meant to buy and then sell - not outright short a positions, right?

Can we add a check after the limit order is placed before the sell limit order is placed?

@tyler: Like any market maker, this strategy can indeed go both long and short.

Anyway, this strategy has no interest whatsoever.

@Luca -- is there a way to update the slippage model to give worse treatment to odd lots (orders of less than 100 shares)? Because odd lot orders aren't printed to the tape, they require the spread to cross their limit price before they fill (basically the opposite of round lot limit orders, which earn the spread).

A lot of time has gone by but this wound up in an external discussion so I was looking at it again and I have some thoughts I wish I had pointed out before.
Here again are some records on GSM.
http://sellerline.com/stocks/quantopian/a_simple_market_making_algo_tracking_GSM.txt
Simultaneous buy and sell limit orders are set. The strategy appears to be, if the price goes down by 1 penny, buy some. If it goes up by a penny, switch from long to short. It is targeting both long and short at 1 cent movement triggers.
When a limit is triggered, it doesn't always even cancel the other, like here, it sells -327 of the -689 (would have taken it from +344 shares to -345 shares if that had all been filled), then buys 1 share in the next minute (264) from an order that was open previously in minute 261. How is all of this realistic? It's best when trying to illustrate something to make it as simple as possible. Pick high volume stocks. Don't try to invest beyond their volume (unless that's an essential part of the bug). Invest less, to likely be fully filled more often. Focus on just buy limit or sell limit if possible. Use as few stocks as you can unless that might mask part of the problem. Go with just long or just short if reasonable.

2017-01-04 10:51 _t:455 INFO  261  Buy                      1  GSM   9.64     344 limit  9.63        6  0.62  0fd5
2017-01-04 10:51 _t:455 INFO  261  Sell                  -689  GSM   9.64     344 limit  9.65        6  0.62  b622
2017-01-04 10:53 _t:455 INFO  263    Sold         -327|0|-689  GSM   9.65      17 limit  9.65        0  0.63  b622
2017-01-04 10:54 _t:455 INFO  264    Bot                    1  GSM   9.62      18 limit  9.63       -0  0.64  0fd5
2017-01-04 10:58 _t:455 INFO  268    Sold       -57|-327|-689  GSM   9.65     -39 limit  9.65       -0  0.63  b622
2017-01-04 10:59 _t:455 INFO  269    Sold       -44|-384|-689  GSM   9.65     -83 limit  9.65       -0  0.63  b622
2017-01-04 11:00 _t:455 INFO  270     cncl               -689  GSM   9.61     -83 limit  9.65        4  0.62  b622


As the output shows, GSM is also a mix of odd and round lots (regarding the mention by Viridian above).
Also by the way I don't think these are even the fabled bracket orders I hear about elsewhere, which as I understand it would say, if the price goes up, sell at limit but if it goes down, sell at stop. There are no stops, so isolating to limit only was a step toward simplifying perhaps.

Enough people with far more market knowledge than myself have made mention of a limit bug on Quantopian of some sort involving the slippage model, and are confident enough about it that I trust there's something there, however I find this and other examples I've seen (though surely well-intended) not very helpful, sorry.

For Q to have reason to look into a bug or have a pretty good idea of what to do about it, or for authors here to be convinced to adopt a particular custom slippage model or work around a given problem, an example that is real-world-worthy and quite clear can probably be developed, by someone who knows these things well or by several of us together. If anyone wants to give it a shot, I'll be happy to run TradeInfo on it for that nicely readable output and if we all chip away at it, maybe some progress can be made for positive dents in it.

1. +08% to Apr 7. Xander original. Default slippage. Rosy returns. Run date: Dec 2017
2. -09% to Apr 7. Luca, same except using his slippage model.
3. -20% to Apr 7. Xander original. Q's newer(?) default slippage. Worst returns. Run date: Jan 2019

0
Backtest from to with initial capital
Total Returns
--
Alpha
--
Beta
--
Sharpe
--
Sortino
--
Max Drawdown
--
Benchmark Returns
--
Volatility
--
 Returns 1 Month 3 Month 6 Month 12 Month
 Alpha 1 Month 3 Month 6 Month 12 Month
 Beta 1 Month 3 Month 6 Month 12 Month
 Sharpe 1 Month 3 Month 6 Month 12 Month
 Sortino 1 Month 3 Month 6 Month 12 Month
 Volatility 1 Month 3 Month 6 Month 12 Month
 Max Drawdown 1 Month 3 Month 6 Month 12 Month
from quantopian.pipeline import Pipeline, CustomFilter
from quantopian.algorithm import attach_pipeline, pipeline_output
from quantopian.pipeline.factors import Latest
from quantopian.pipeline.data.builtin import USEquityPricing
from quantopian.pipeline.factors import AverageDollarVolume
import numpy as np
import math

DEFAULT_VOLUME_SLIPPAGE_BAR_LIMIT = 0.025

class LiquidityExceeded(Exception):
pass

class myVolumeShareSlippage(slippage.SlippageModel):
"""Model slippage as a function of the volume of shares traded.
"""

def __init__(self, volume_limit=DEFAULT_VOLUME_SLIPPAGE_BAR_LIMIT,
price_impact=0.1):

self.volume_limit = volume_limit
self.price_impact = price_impact

slippage.SlippageModel.__init__(self)

def process_order(self, data, order):
volume = data.current(order.asset, "volume")

max_volume = self.volume_limit * volume

# price impact accounts for the total volume of transactions
# created against the current minute bar
remaining_volume = max_volume - self.volume_for_bar
if remaining_volume < 1:
# we can't fill any more transactions
#raise LiquidityExceeded()
return None, None

# the current order amount will be the min of the
# volume available in the bar or the open amount.
cur_volume = int(min(remaining_volume, abs(order.open_amount)))

if cur_volume < 1:
return None, None

# tally the current amount into our total amount ordered.
# total amount will be used to calculate price impact
total_volume = self.volume_for_bar + cur_volume

volume_share = min(total_volume / volume,
self.volume_limit)

price = data.current(order.asset, "close")

simulated_impact = volume_share ** 2 \
* math.copysign(self.price_impact, order.direction) \
* price
impacted_price = price + simulated_impact

if order.limit:
# this is tricky! if an order with a limit price has reached
# the limit price, we will try to fill the order. do not fill
# these shares if the impacted price is worse than the limit
# price. return early to avoid creating the transaction.

# buy order is worse if the impacted price is greater than
# the limit price. sell order is worse if the impacted price
# is less than the limit price
if (order.direction > 0 and impacted_price > order.limit) or \
(order.direction < 0 and impacted_price < order.limit):
return None, None

# For "non-marketable" limit orders (limit price has been crossed)
# the final price must be the limit price.
# To disinguish between marketable and non-marketable limit
# order we can use the following check:
# if both open and close price are below/above (buy/sell)
# the limit price the order is markettable
# if open price is above (or below for sell) limit price
# and close price is below (or above for sell) limit price,
# then the order is non-marketable

open_price = data.current(order.asset, "open")
# Note: no need to check for close price if we are here
non_marketable = (order.direction > 0 and open_price > order.limit) or \
(order.direction < 0 and open_price < order.limit)

if non_marketable:
impacted_price = order.limit

return (
impacted_price,
math.copysign(cur_volume, order.direction)
)

def initialize(context):
###set_slippage(myVolumeShareSlippage(volume_limit=DEFAULT_VOLUME_SLIPPAGE_BAR_LIMIT, price_impact=0.1))

pipe = Pipeline()
#pricing = USEquityPricing.close.latest
dollar_volume = AverageDollarVolume(window_length=30)
attach_pipeline(pipe, 'pipe')
schedule_function(flush_portfolio, date_rules.month_start(), time_rules.market_close(minutes=50))

for i in range(1,300,10):
schedule_function(limit_orders, date_rules.every_day(), time_rules.market_open(minutes=i))
schedule_function(flush_orders, date_rules.every_day(), time_rules.market_open(minutes=(i+9)))

def limit_orders(context,data):
currprice = data.current(context.output.index,'price')
nstocks = max(10,len(context.output))
for stock in currprice.iteritems():
try:
order_target_percent(stock[0], 1./nstocks,style = LimitOrder(stock[1]-0.01))
order_target_percent(stock[0],-1./nstocks,style = LimitOrder(stock[1]+0.01))
except:
pass

def flush_orders(context,data):
for stock, orders in get_open_orders().iteritems():
for order in orders:
cancel_order(order)

def flush_portfolio(context,data):
for stock in context.portfolio.positions:
order_target_percent(stock,0)
There was a runtime error.