Trading Strategy: Moving Average Mean Reversion

Hi All,

I'd like to share with you a simple mean reversion technique that relies on moving averages. In short, the idea is that the mean-reversion signals can be approximated by intersections of different-length moving averages. This is best made clear by the following illustration:

http://i.imgur.com/5oi3yao.png

In which the data is from a 3,000-day dataset of a stock's closing price. In this model, we generally have one "long" moving average and one "short" moving average (in this case, 90 and 30 days, respectively). We trade when these lines intersect, then choosing to buy or sell based on the direction of the trend (whether the short MA is rising or falling). I note that we don't trade exactly when the lines intersect, but rather when they are sufficiently close (by some user-defined metric). While this is not as statistically strong as mean reversion could be, it's a reasonable approximation with plenty of nice properties because of the lag between the two MAs: a reasonable buy/sell strategy with clear signals that translates into having an effective stop-loss from any peak, except for cases of sudden and severe price crashes.

I've attached a backtest that I ran on some tech stocks between 2008 and 2010, with MA periods of 30 and 10 days.

I'm somewhat new to Quantopian and I didn't have much time to write my algorithm, so I apologize for some of the crude techniques I used in my code, which I intend to fix in future versions. I plan to rewrite the part that decides how much to invest (it's currently mostly hand-tuned with guesstimates), to add a more sophisticated stop-loss, and and to improve the heuristic for determining whether a security is trending negatively or positively. I also need to make my algorithm start shorting stocks. There's generally a great deal of calibration to be done to this algorithm. It would also probably be useful to develop some analyses that determine the best MA periods to use.

Feel free to play around with this algorithm and see if you find anything interesting!

590
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
import numpy as np

def initialize(context):
# list of tradeable securities. Below: an example
sid(24),sid(5061),sid(3766),sid(1900)]
# 30% max drawdown
context.max_drawdown = 0.3
context.day_counter = 0
# Historical price database
context.database = {}
# Initiate an empty list of prices for each security
context.database[security] = []

def handle_data(context, data):
context.day_counter += 1
context.database[security].append(data[security]['price'])
# We need to collect 30 days of data before we start trading.
if context.day_counter >= 30:
# past 10 and past 30 prices, respectively
past10 = (context.database[security][-10:])[::-1]
past30 = (context.database[security][-30:])[::-1]
mean10 = np.mean(past10)
mean30 = np.mean(past30)
# has the asset been increasing in value for the last three days?
trend10p = past10[0]>=past10[1]>=past10[2]>=past10[3]
# or falling?
trend10n = past10[0]<=past10[1]<=past10[2]<=past10[3]

# If we hold a security, we want to test if it is time to sell.
# (Securities even with amount 0 may still be listed in portfolio, so it's
#  necessary to double-check)
if security in context.portfolio.positions.keys():
#and context.portfolio.positions[security].amount > 0:
# Checking that the 10-day moving average is below the 30-day moving average,
# and that the price of the security has been falling for four days
if mean10 < mean30*0.95 and trend10p:
order(security, -context.portfolio.positions[security].amount)
# 8% stop-loss
elif context.portfolio.positions[security].cost_basis*0.92 >=data[security]['price']:
order(security, -context.portfolio.positions[security].amount)

# If we don't hold a security: check if we should buy it.
# Check if the 10-day moving average is above the 30-day moving average,
# and that the price of the security has been rising for four days
elif mean10>mean30*1.05 and trend10n:
# But if the security appears to be falling in price long-term,
# then going for mean-reversion might not be a good idea, so we pass.
if past30[0]<=past30[5]<=past30[10]<=past30[15]:
pass
elif security not in context.portfolio.positions.keys():
# The heuristic below is to prevent investing too much in one security.
p = data[security]['price']
toInvest = (context.portfolio.cash) * (context.max_drawdown**0.75)
numShares = max(0,np.round(toInvest/p))
order(security, numShares)

There was a runtime error.
9 responses

Pardon the bump, but this is a notebook I made to help visualize like your photo. The variables are accessible as well.

27
Notebook previews are currently unavailable.

Hi John,

I spent some time looking over this over, and I have to give it to you: this is a very well calculated and intuitive algorithm.
I was able to make some pretty good improvements to the algorithm's readability and performance.

As far as lines of code goes, I sought out to make some readability improvements by streamlining some of the more rigorous methods using Quantopian builtins. I removed the need for a price database and a 30-day waiting period using the history function. I also used the records as Darrell suggested to track the positions and leverage. In order to ensure the orders went in correctly, I changed those to orders by percentages. After that, I noticed that you had placed a "pass" statement where the algorithm called for a "continue" statement. Without your comments, I definitely would have missed it.

In terms of algorithm logic, I reversed the buy/sell initiations to activate when the price over the last 4 days increases/decreases in order to ride the mean reversion momentum up as opposed to guessing whether it will or not. As an aside, this makes the algorithm more of a hybrid than mean reverting, but I was able to gain some get some good improvements to the sharpe ratio. I also removed a security in your sample that was delisted in 2010 to view the algorithm's performance to date. It performs very well, great job.

Best,
Lotanna Ezenwa

371
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
import numpy as np

def initialize(context):
# list of tradeable securities. Below: an example
sid(24),sid(5061),sid(3766),sid(1900)]
# 50% max drawdown
context.max_drawdown = .5
context.day_counter = 0

def handle_data(context, data):
# past 10 and past 30 prices, respectively
past10 = history(10,'1d','close_price')[security][::-1]
past30 = history(30,'1d','close_price')[security][::-1]
mean10 = np.mean(past10)
mean30 = np.mean(past30)
# has the asset been increasing in value for the last three days?
trend10p = past10[0]>=past10[1]>=past10[2]>=past10[3]
# or falling?
trend10n = past10[0]<=past10[1]<=past10[2]<=past10[3]

# If we hold a security, we want to test if it is time to sell.
# (Securities even with amount 0 may still be listed in portfolio, so it's
#  necessary to double-check)
if security in context.portfolio.positions.keys():
#and context.portfolio.positions[security].amount > 0:
# Checking that the 10-day moving average is below the 30-day moving average,
# and that the price of the security has been falling for four days
if mean10 < mean30*0.95 and trend10p:
order_target_percent(security, 0)
# 8% stop-loss
elif context.portfolio.positions[security].cost_basis*0.92 >=data[security]['price']:
order_target_percent(security, 0)

# If we don't hold a security: check if we should buy it.
# Check if the 10-day moving average is above the 30-day moving average,
# and that the price of the security has been rising for four days
elif mean10>mean30*1.05 and trend10n:
# But if the security appears to be falling in price long-term,
# then going for mean-reversion might not be a good idea, so we continue.
if past30[0]<=past30[5]<=past30[10]<=past30[15]:
continue
elif security not in context.portfolio.positions.keys():
# The heuristic below is to prevent investing too much in one security.
p = data[security]['price']
toInvest = (context.portfolio.cash) * (context.max_drawdown**0.75)
numShares = max(0,np.round(toInvest/p))
order(security, numShares)

record(leverage=context.account.leverage)
record(num_positions=len(context.portfolio.positions.keys()))


There was a runtime error.
Disclaimer

The material on this website is provided for informational purposes only and does not constitute an offer to sell, a solicitation to buy, or a recommendation or endorsement for any security or strategy, nor does it constitute an offer to provide investment advisory services by Quantopian. In addition, the material offers no opinion with respect to the suitability of any security or specific investment. No information contained herein should be regarded as a suggestion to engage in or refrain from any investment-related course of action as none of Quantopian nor any of its affiliates is undertaking to provide investment advice, act as an adviser to any plan or entity subject to the Employee Retirement Income Security Act of 1974, as amended, individual retirement account or individual retirement annuity, or give advice in a fiduciary capacity with respect to the materials presented herein. If you are an individual retirement or other investor, contact your financial advisor or other fiduciary unrelated to Quantopian about whether any given investment idea, strategy, product or service described herein may be appropriate for your circumstances. All investments involve risk, including loss of principal. Quantopian makes no guarantees as to the accuracy or completeness of the views expressed in the website. The views are subject to change, and may have become unreliable for various reasons, including changes in market conditions or economic circumstances.

It would be helpful to see if this is still profitable without the hand-picked stocks, but rather using some sort of dynamic universe.

Yeah, I'm currently working on editing the logic to encompass some other stocks using pipeline. I tried putting a few in there at random and the performance was lacking.

My hypothesis at the moment is that new securities selected based on fundamentals will do just as well. I'll post the update when I finish.

Same algorithm on a universe of 600 stocks. It might be worth double checking I didn't introduce any bug.

81
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.algorithm import attach_pipeline, pipeline_output
from quantopian.pipeline import Pipeline
from quantopian.pipeline.data.builtin import USEquityPricing
from quantopian.pipeline.data import morningstar
from quantopian.pipeline.factors import CustomFactor, SimpleMovingAverage, Latest, EWMA, EWMSTD, Returns, ExponentialWeightedMovingAverage, AverageDollarVolume
from quantopian.pipeline.filters.morningstar import IsPrimaryShare
import pandas as pd
import numpy as np

def high_volume_universe(min_price = 0., min_volume = 0.):
"""
Computes a security universe based on nine different filters:

1. The security is common stock
2 & 3. It is not limited partnership - name and database check
4. The database has fundamental data on this stock
5. Not over the counter
6. Not when issued
7. Not depository receipts
8. Is Primary share
9. Has high dollar volume

Returns
-------
A ranked AverageDollarVolume factor that's filtered on the nine criteria
"""
common_stock = morningstar.share_class_reference.security_type.latest.eq('ST00000001')
not_lp_name = ~morningstar.company_reference.standard_name.latest.matches('.* L[\\. ]?P\.?$') not_lp_balance_sheet = morningstar.balance_sheet.limited_partnership.latest.isnull() have_data = morningstar.valuation.market_cap.latest.notnull() not_otc = ~morningstar.share_class_reference.exchange_id.latest.startswith('OTC') not_wi = ~morningstar.share_class_reference.symbol.latest.endswith('.WI') not_depository = ~morningstar.share_class_reference.is_depositary_receipt.latest primary_share = IsPrimaryShare() # Combine the above filters. tradable_filter = (common_stock & not_lp_name & not_lp_balance_sheet & have_data & not_otc & not_wi & not_depository & primary_share) price = SimpleMovingAverage(inputs=[USEquityPricing.close], window_length=21, mask=tradable_filter) volume = SimpleMovingAverage(inputs=[USEquityPricing.volume], window_length=21, mask=tradable_filter) full_filter = tradable_filter & (price >= min_price) & (volume >= min_volume) high_volume_tradable = AverageDollarVolume( window_length=21, mask=full_filter ).rank(ascending=False) return high_volume_tradable def make_pipeline(context): dollar_volume = high_volume_universe(min_price=5.0) full_filter = (dollar_volume <= context.universe_size) pipe = Pipeline() pipe.set_screen(full_filter) pipe.add(dollar_volume, "liquid_stocks") return pipe def initialize(context): context.universe_size = 600 # how many securities to deal with every day context.max_cash_per_sec = 10000 # limit the amount of money on a single security context.dont_buys = security_lists.leveraged_etf_list set_do_not_order_list(context.dont_buys) attach_pipeline(make_pipeline(context), 'factors') schedule_function(rebalance, date_rules.every_day(), time_rules.market_open()) schedule_function(log_stats, date_rules.every_day(), time_rules.market_close()) def before_trading_start(context, data): # get and clean pipeline results results = pipeline_output('factors') results = results.replace([np.inf, -np.inf], np.nan) results = results.dropna() # remove stocks we don't want to trade results = results.drop(context.dont_buys, axis=0, errors='ignore') print 'Basket of stocks %d' % (len(results)) # save stock universe context.tradeables_secs = results['liquid_stocks'].index def rebalance(context, data): context.num_events = 0 # just for logging # # Let's decide what position to close and which new one to enter # new_secs = set() positions_to_close = {} all_sec = list( set(context.tradeables_secs) | set(context.portfolio.positions.keys()) ) current_price = data.current(all_sec, 'price') _past10 = data.history(all_sec, fields='close', bar_count=10, frequency='1d')[::-1] _past30 = data.history(all_sec, fields='close', bar_count=30, frequency='1d')[::-1] for security in all_sec: # past 10 and past 30 prices, respectively past10 = _past10[security] past30 = _past30[security] mean10 = np.mean(past10) mean30 = np.mean(past30) # has the asset been increasing in value for the last three days? trend10p = past10[0]>=past10[1]>=past10[2]>=past10[3] # or falling? trend10n = past10[0]<=past10[1]<=past10[2]<=past10[3] # If we hold a security, we want to test if it is time to sell. # (Securities even with amount 0 may still be listed in portfolio, so it's # necessary to double-check) if security in context.portfolio.positions.keys(): #and context.portfolio.positions[security].amount > 0: # Checking that the 10-day moving average is below the 30-day moving average, # and that the price of the security has been falling for four days if mean10 < mean30*0.95 and trend10p: positions_to_close[security] = context.portfolio.positions[security].amount # 8% stop-loss elif context.portfolio.positions[security].cost_basis*0.92 >= current_price[security]: positions_to_close[security] = context.portfolio.positions[security].amount # If we don't hold a security: check if we should buy it. # Check if the 10-day moving average is above the 30-day moving average, # and that the price of the security has been rising for four days elif mean10>mean30*1.05 and trend10n: # But if the security appears to be falling in price long-term, # then going for mean-reversion might not be a good idea, so we continue. if past30[0]<=past30[5]<=past30[10]<=past30[15]: continue elif security not in context.portfolio.positions.keys(): new_secs.add(security) context.num_events = len(new_secs) # for logging # Helper function diff_positions = lambda d1, d2: { k:(d1.get(k,0)-d2.get(k,0)) for k in set(d1) | set(d2) if (d1.get(k,0)-d2.get(k,0)) != 0 } # # Calcualte new positions in our portfolio # current_positions = { sec:position.amount for sec, position in context.portfolio.positions.iteritems() } positions_to_keep = diff_positions(current_positions, positions_to_close) final_secs = new_secs | set(positions_to_keep.keys()) final_positions = {} available_cash_per_sec = context.portfolio.portfolio_value / len(final_secs) available_cash_per_sec = min(available_cash_per_sec, context.max_cash_per_sec) for sec in final_secs: if data.can_trade(sec): amount = available_cash_per_sec / current_price[sec] final_positions[sec] = round(amount) else: log.warn('Security %s missing in data, cannot buy it' % str(sec)) actual_orders = diff_positions(final_positions, current_positions) for sec, amount in actual_orders.iteritems(): log.debug( 'order %s amount %d' % (str(sec), amount) ) order(sec, amount) def log_stats(context, data): record(lever=context.account.leverage, num_pos=len(context.portfolio.positions), num_events=context.num_events)  There was a runtime error. and on a universe of 50 stocks 81 Loading... 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.algorithm import attach_pipeline, pipeline_output from quantopian.pipeline import Pipeline from quantopian.pipeline.data.builtin import USEquityPricing from quantopian.pipeline.data import morningstar from quantopian.pipeline.factors import CustomFactor, SimpleMovingAverage, Latest, EWMA, EWMSTD, Returns, ExponentialWeightedMovingAverage, AverageDollarVolume from quantopian.pipeline.filters.morningstar import IsPrimaryShare import pandas as pd import numpy as np def high_volume_universe(min_price = 0., min_volume = 0.): """ Computes a security universe based on nine different filters: 1. The security is common stock 2 & 3. It is not limited partnership - name and database check 4. The database has fundamental data on this stock 5. Not over the counter 6. Not when issued 7. Not depository receipts 8. Is Primary share 9. Has high dollar volume Returns ------- high_volume_tradable - zipline.pipeline.factor.Rank A ranked AverageDollarVolume factor that's filtered on the nine criteria """ common_stock = morningstar.share_class_reference.security_type.latest.eq('ST00000001') not_lp_name = ~morningstar.company_reference.standard_name.latest.matches('.* L[\\. ]?P\.?$')
not_lp_balance_sheet = morningstar.balance_sheet.limited_partnership.latest.isnull()
have_data = morningstar.valuation.market_cap.latest.notnull()
not_otc = ~morningstar.share_class_reference.exchange_id.latest.startswith('OTC')
not_wi = ~morningstar.share_class_reference.symbol.latest.endswith('.WI')
not_depository = ~morningstar.share_class_reference.is_depositary_receipt.latest
primary_share = IsPrimaryShare()

# Combine the above filters.
tradable_filter = (common_stock & not_lp_name & not_lp_balance_sheet &
have_data & not_otc & not_wi & not_depository & primary_share)

price = SimpleMovingAverage(inputs=[USEquityPricing.close],
volume = SimpleMovingAverage(inputs=[USEquityPricing.volume],

full_filter = tradable_filter & (price >= min_price) & (volume >= min_volume)

window_length=21,
).rank(ascending=False)

def make_pipeline(context):

dollar_volume = high_volume_universe(min_price=5.0)
full_filter = (dollar_volume <= context.universe_size)

pipe = Pipeline()
pipe.set_screen(full_filter)

return pipe

def initialize(context):

context.universe_size = 50 # how many securities to deal with every day
context.max_cash_per_sec = 10000 # limit the amount of money on a single security

attach_pipeline(make_pipeline(context), 'factors')

schedule_function(rebalance, date_rules.every_day(), time_rules.market_open())
schedule_function(log_stats, date_rules.every_day(), time_rules.market_close())

# get and clean pipeline results
results = pipeline_output('factors')
results = results.replace([np.inf, -np.inf], np.nan)
results = results.dropna()

# remove stocks we don't want to trade
print 'Basket of stocks %d' % (len(results))

# save stock universe

def rebalance(context, data):

context.num_events = 0 # just for logging

#
# Let's decide what position to close and which new one to enter
#
new_secs = set()
positions_to_close = {}
all_sec = list( set(context.tradeables_secs) | set(context.portfolio.positions.keys()) )

current_price = data.current(all_sec, 'price')
_past10 = data.history(all_sec, fields='close', bar_count=10, frequency='1d')[::-1]
_past30 = data.history(all_sec, fields='close', bar_count=30, frequency='1d')[::-1]

for security in all_sec:

# past 10 and past 30 prices, respectively
past10 = _past10[security]
past30 = _past30[security]
mean10 = np.mean(past10)
mean30 = np.mean(past30)
# has the asset been increasing in value for the last three days?
trend10p = past10[0]>=past10[1]>=past10[2]>=past10[3]
# or falling?
trend10n = past10[0]<=past10[1]<=past10[2]<=past10[3]

# If we hold a security, we want to test if it is time to sell.
# (Securities even with amount 0 may still be listed in portfolio, so it's
#  necessary to double-check)
if security in context.portfolio.positions.keys():
#and context.portfolio.positions[security].amount > 0:
# Checking that the 10-day moving average is below the 30-day moving average,
# and that the price of the security has been falling for four days
if mean10 < mean30*0.95 and trend10p:
positions_to_close[security] = context.portfolio.positions[security].amount
# 8% stop-loss
elif context.portfolio.positions[security].cost_basis*0.92 >= current_price[security]:
positions_to_close[security] = context.portfolio.positions[security].amount

# If we don't hold a security: check if we should buy it.
# Check if the 10-day moving average is above the 30-day moving average,
# and that the price of the security has been rising for four days
elif mean10>mean30*1.05 and trend10n:
# But if the security appears to be falling in price long-term,
# then going for mean-reversion might not be a good idea, so we continue.
if past30[0]<=past30[5]<=past30[10]<=past30[15]:
continue
elif security not in context.portfolio.positions.keys():

context.num_events = len(new_secs) # for logging

# Helper function
diff_positions = lambda d1, d2: { k:(d1.get(k,0)-d2.get(k,0)) for k in set(d1) | set(d2) if (d1.get(k,0)-d2.get(k,0)) != 0 }

#
# Calcualte new positions in our portfolio
#
current_positions = { sec:position.amount for sec, position in context.portfolio.positions.iteritems() }
positions_to_keep = diff_positions(current_positions, positions_to_close)
final_secs = new_secs | set(positions_to_keep.keys())

final_positions = {}
available_cash_per_sec = context.portfolio.portfolio_value / len(final_secs)
available_cash_per_sec = min(available_cash_per_sec, context.max_cash_per_sec)
for sec in final_secs:
amount = available_cash_per_sec / current_price[sec]
final_positions[sec] = round(amount)
else:
log.warn('Security %s missing in data, cannot buy it' % str(sec))

actual_orders = diff_positions(final_positions, current_positions)
for sec, amount in actual_orders.iteritems():
log.debug( 'order %s amount %d' % (str(sec), amount) )
order(sec, amount)

def log_stats(context, data):

record(lever=context.account.leverage,
num_pos=len(context.portfolio.positions),
num_events=context.num_events)

There was a runtime error.

How about testing it with a 3X ETF

if 10day.mean > 30day.mean, we long (buy). If 10day.mean < 30day.mean, shall we short-sell ?

@Tory, It's a little bit more involved. It looks for whether the 10day mean is greater than 95% of the 30day mean. What the algo is trying to compensate for is that moving averages are a lagging indicator, so it's fudging the signals so that they trip earlier. It also checks for a 3-day continuous uptrend as a confirmation signal. And visa versa for selling. Also has a 8% stop loss. It all sounds good in theory, but as Luca's posts pointed out, it's not the algorithm that produces those impressive results in the initial backtests -- it was the bias introduced by the hand-selected stocks.

The difficulty with crossovers is that 1. due to the smoothing they're a lagging indicator, and 2. the frequency and amplitude of the price waveform is too variable for fixed-period crossovers to discern between signal and noise -- they don't do a good job of detecting whether a recent price move is establishing new momentum as opposed to a temporary move that will quickly revert. So just as often as not by the time the signal gets triggered the price is already moving in the other direction.