Back to Community
S&C V32.1 pg36 Outperform the Market with Sector ETFs

I implemented the algorithm from Stocks and Commodities Magazine V32.1 page 36 by Todd and Steven Winkler.

Algorithm:
Select worst 3 performing ETF sectors at end of year as measured by Returns over last year
Hold them 1 year
Repeat

Modifications from paper:
If trading starts outside of last day of December buy lowest ranked
Rebalance portfolio on a daily basis because Quantopian doesn't have GTC orders
- It means that if orders aren't filled on day placed they are cancelled leaving an under positioned portfolio
- It keeps leverage at about 1 but it generates more trades (which QT includes commissions and slippage for)

There is also an option to re-rank on a monthly basis. This did not work as well in my backtesting.

I couldn't verify all the backtests from the paper because Quantopian only has historical data to about 2002. However the basic premise of the paper is confirmed and the backtest produces better returns than just holding SPY.

This algorithm however is not for the faint of heart because 50%+ drawdowns can occur.

Cheers,
John

Clone Algorithm
66
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
'''
Stocks and Commodities V32.1 page 36
OUtperform the Market with Sector ETF's
by Todd and Steven Winkler

Algorithm:
Select worst 3 performing sectors at end of year
Hold them 1 year
Repeat

Modifications from paper:
If trading starts outside of last day of december buy lowest ranked
Rebalance portfolio on a daily basis because Quantopian doesn't have GTC orders
- It also keeps leverage ~1
Option to re-rank and rebalance on a monthly basis

John Glossner
[email protected]
http://Linkedin.com/in/glossner

MIT License
'''

from quantopian.pipeline import Pipeline, CustomFilter
from quantopian.algorithm import attach_pipeline, pipeline_output
from quantopian.pipeline.data.builtin import USEquityPricing
from quantopian.pipeline.factors import Latest, CustomFactor, Returns

import numpy as np
import pandas as pd

        
def initialize(context):
    context.re_rank_monthly = False        #Change to True for monthly re-ranking
    context.desired_leverage = 1.0         #Change up or down as desired
    
    #Quantopian doesn't have a yearly frequency. Avoids first 30 minutes of trading. 
    schedule_function(trade,     date_rules.month_end(), time_rules.market_open(minutes=30))
    schedule_function(rebalance, date_rules.every_day(), time_rules.market_open(minutes=30))
    schedule_function(plots, date_rules.every_day(), time_rules.market_close(minutes=1))

    context.ranks = pd.DataFrame()    
    attach_pipeline(my_pipeline(context), 'my_pipeline')   
    return
    
    
def before_trading_start(context, data):
    ''' Runs daily before the start of trading '''
    context.output = pipeline_output('my_pipeline').dropna()
    return
 

def trade (context, data):
    '''
    If month = December or no positions 
        get 3 worst performing sectors
        If any open positions on 3 worst performing keep them
        Exit all other positions
        Buy remaining worst positions
    Rebalance portfolio every month
    Option to re_rank every month and rotate positions
    '''
    log.info('\n' + '*************************************')
         
    #if month is December or no positions held
    if get_datetime().month == 12 or len(context.portfolio.positions) == 0:    
        context.ranks = context.output.sort_values(by='sector_rank', axis=0, ascending=True).rank().iloc[:3]  #lowest 3
        log.info(context.output.sort_values(by='sector_rank', axis=0, ascending=True).tail(9))   
        
    #if re-ranking monthly
    if context.re_rank_monthly:
        context.ranks = context.output.sort_values(by='sector_rank', axis=0, ascending=True).rank().iloc[:3]  #lowest 3
        log.info(context.output.sort_values(by='sector_rank', axis=0, ascending=True).tail(9))   

    #Check portfolio positions and close any not in the bottom
    if len(context.portfolio.positions) > 0:  
        for pos in context.portfolio.positions:  
            if context.portfolio.positions[pos].amount != 0:
                if data.can_trade(pos) and not get_open_orders(pos) and not in_lowest(pos, context.ranks):
                    order_target_percent(pos, 0)
                    log.info('---- Exiting Order for ----' + str(pos))
                                       
    #place orders for lowest 3. If already existing order will rebalance to desired_leverage/3
    rebalance(context, data)
    
    return

def rebalance(context, data):
    ''' 
    Called daily because Quantopian doesn't have GTC orders
    Issue is that it produces more trades than you would do in real trading
    '''
    for security in context.ranks.index:       
        if data.can_trade(security):
            order_target_percent(security, context.desired_leverage/len(context.ranks.index))
            log.info('++++ placing order for ++++ ' + str(security))          
    return
    

def in_lowest(pos, ranks):
    if ranks[ ranks.index == pos ].empty : return False
    return True
    

def handle_data(context, data):
    ''' Runs Every Minute '''
    pass

def plots(context, data):
    record(leverage=context.account.leverage)
    return

def my_pipeline(context):
    # Custom filter to return only sids in the list
    my_sid_filter = SidInList(
        sid_list = (                                      # Trade Start Date
            symbol('XLY').sid,  # XLY Consumer Discretionary 1998 12 22 
            symbol('XLF').sid,  # XLF Financial              1998 12 22 
            symbol('XLK').sid,  # XLK Technology             1998 12 22  
            symbol('XLE').sid,  # XLE Energy                 1998 12 22  
            symbol('XLV').sid,  # XLV Health Care            2014 12 04  
            symbol('XLI').sid,  # XLI Industrial             2014 12 10
            symbol('XLP').sid,  # XLP Consumer Staples       1998 12 22   
            symbol('XLB').sid,  # XLB Materials              1998 12 22  
            symbol('XLU').sid,  # XLU Utilities              2014 12 04
        )
    )
    
    sector_rank = Returns(
            inputs=[USEquityPricing.close],
            window_length=252,
            mask = my_sid_filter)

    pipe = Pipeline(
            columns = {
                    'sector_rank' : sector_rank,
                    },
            screen = my_sid_filter,
            )
    
    return pipe

class SidInList(CustomFilter):
    """
    Filter returns True for any SID included in parameter tuple passed at creation.
    Usage: my_filter = SidInList(sid_list=(23911, 46631))
    https://www.quantopian.com/posts/using-a-specific-list-of-securities-in-pipeline
    """    
    inputs = []
    window_length = 1
    params = ('sid_list',)

    def compute(self, today, assets, out, sid_list):
        out[:] = np.in1d(assets, sid_list)       
        
        
'''
MIT License

Copyright (c) 2017 John Glossner

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
'''
 
There was a runtime error.
5 responses

John ,

Interesting findings.
BTW
Calculation of returns over last year require not 252 but 253 data points.
So line 131 should be:

window_length=253,

Vladimir,

Thanks for the comment.

It is correct that I don't use exact number of trading days per year to compute Returns but over the period of the backtest the average trading days were 251.7 per year.

Do you have a reference on why 253 should be used?

Thanks,
John

John,

Rounded 251.7 is 252.
To calculate 1 day return you need today's price and yesterday's price .
To calculate 252 days return you need today's price and price 252 days ago.
Total bars in data window to calculate 252 days return should be 253.

Thanks.

Attached is cleaned up code with:
- Rebalance on leverage too high
- Separate ranking from trading (fixes exit position bug on large portfolios)
- Allow orders to fill for 15 days in January (large portfolio)
- Tries to avoid very small orders on rebalancing
- Returns with 253 day window

Clone Algorithm
66
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
'''
Stocks and Commodities V32.1 page 36
OUtperform the Market with Sector ETF's
by Todd and Steven Winkler

Algorithm:
Select worst 3 performing sectors at end of year
Hold them 1 year
Repeat

Modifications from paper:
If trading starts outside of last day of december buy lowest ranked
Rebalance portfolio on a daily basis because Quantopian doesn't have GTC orders
- It also keeps leverage ~1
Option to re-rank and rebalance on a monthly basis

John Glossner
[email protected]
http://Linkedin.com/in/glossner

MIT License

2017 03 16 Rebalance on leverage too high
2017 03 15 added function rank() which now only ranks and doesn't trade
           - fixes issue that orders headed to 0 might not complete
           rebalance only runs in January for yearly rebalance
           out_of_bands() limits QT placing very small trades
2017 03 12 updated Returns window to 253 (year + 1)
'''

from quantopian.pipeline import Pipeline, CustomFilter
from quantopian.algorithm import attach_pipeline, pipeline_output
from quantopian.pipeline.data.builtin import USEquityPricing
from quantopian.pipeline.factors import Latest, CustomFactor, Returns

import numpy as np
import pandas as pd

        
def initialize(context):
    context.re_rank_monthly = False        #Change to True for monthly re-ranking
    context.desired_leverage = 1.0         #Change up or down as desired
    
    #Quantopian doesn't have a yearly frequency. Avoid first 30 minutes of trading. 
    schedule_function(rank,      date_rules.month_end(), time_rules.market_open(minutes=30))
    schedule_function(trade,     date_rules.every_day(), time_rules.market_open(minutes=30))
    schedule_function(rebalance, date_rules.every_day(), time_rules.market_open(minutes=30))
    schedule_function(plots,     date_rules.every_day(), time_rules.market_close(minutes=1))

    context.ranks = pd.DataFrame()    
    attach_pipeline(my_pipeline(context), 'my_pipeline')   
    return
    
    
def before_trading_start(context, data):
    ''' Runs daily before the start of trading '''
    context.output = pipeline_output('my_pipeline').dropna()
    return
 

def rank(context, data):
    '''
    rank positions by sector_rank (pipeline filter)
    
    If month = December or no positions 
        get 3 worst performing sectors
        
    * Option to re_rank every month and rotate positions
    '''
    if get_datetime().month == 12 or  \
       len(context.portfolio.positions) == 0 or \
        context.re_rank_monthly :
            log.info('\n' + '*************************************')
            context.ranks = context.output.sort_values(by='sector_rank', axis=0, ascending=True).rank().iloc[:3]  #lowest 3
            log.info(context.output.sort_values(by='sector_rank', axis=0, ascending=True).tail(9))             
    return


def trade(context, data):
    ''' 
    Close positions when ranking changes.
    If no securities held in portfolio, buy the lowest ranked
    Open positions when ranking changes
    '''
    #Close positions when ranking changes
    if len(context.portfolio.positions) != 0:  
        for pos in context.portfolio.positions:  
            if not in_lowest(pos, context.ranks) and data.can_trade(pos):
                ord = order_target_percent(pos, 0)
                log_order(context, data, ord, pos, "trade(): Exiting higher ranked position")
    
    #No positions in portfolio. Open for first time
    if len(context.portfolio.positions) == 0:  
        for security in context.ranks.index:
            ord = order_target_percent(security, context.desired_leverage/len(context.ranks))
            log_order(context, data, ord, security, "trade(): No positions")
        return
    
    #Open Newly identified positions when ranking changes
    for security in context.ranks.index:
        if not in_portfolio(context, data, security):
            ord = order_target_percent(security, context.desired_leverage/len(context.ranks))
            log_order(context, data, ord, security, "trade(): New ranking")
            
    return

def rebalance(context, data):
    '''
    Called daily because Quantopian doesn't have GTC orders. 
    Places orders to bring portfolio up to desired percentage of value
    - Happens when an order is partially filled
    - limited to January unless rebalancing monthly
    
    note: loops could be combined but it is less intuitive
    '''
    #New rebalancing - place for first 15 days of month for large orders that don't fill in a day
    if get_datetime().month == 1 and get_datetime().day < 15 :  #First 15 days of January when ranks change
        if len(context.portfolio.positions) != 0:          
            for pos in context.portfolio.positions:  
                if data.can_trade(pos) and not get_open_orders(pos) and \
                   in_lowest(pos, context.ranks) and out_of_bands(context, data, pos):
                        ord=order_target_percent(pos, context.desired_leverage/len(context.ranks))
                        log_order(context, data, ord, pos, "rebalance() - replace orders")
    
    #If Monthly re-ranking                    
    if context.re_rank_monthly:    
        if len(context.portfolio.positions) != 0:          
            for pos in context.portfolio.positions:  
                if data.can_trade(pos) and not get_open_orders(pos) and \
                   in_lowest(pos, context.ranks) and out_of_bands(context, data, pos):
                        ord=order_target_percent(pos, context.desired_leverage/len(context.ranks))
                        log_order(context, data, ord, pos, "rebalance() - monthly rebalancing")

                        
    #Leverage too high
    if context.account.leverage > context.desired_leverage:
        for pos in context.portfolio.positions:  
            if data.can_trade(pos) and not get_open_orders(pos):
                ord=order_target_percent(pos, context.desired_leverage/len(context.ranks))
                log_order(context, data, ord, pos, "rebalance() - leverage too high")
    
    return    


def in_lowest(pos, ranks):
    '''
    Determine if a specific security is in a list of securities (called ranks)
    The function is general (any list) but it is named for clarity
    '''
    if ranks[ ranks.index == pos ].empty : return False
    return True

def in_portfolio(context, data, security):
    '''
    Determine if a specific security is in the portfolio
    '''
    for pos in context.portfolio.positions:
        if pos.sid == security.sid : return True
    return False

def out_of_bands(context, data, pos, band_percent=0.05):
    '''
    Quantopian when rebalancing will place very small trades.
    This inhibits trades when the position is within band_percent of the desired value
    '''
    if len(context.ranks) == 0: return False
    
    total_ranked_securities = len(context.ranks)
    target_position_percent = context.desired_leverage/total_ranked_securities
    guard_low =  (1.0 - band_percent)*target_position_percent
    guard_high = (1.0 + band_percent)*target_position_percent

    position_percent =  context.portfolio.positions[pos].amount * \
                        context.portfolio.positions[pos].last_sale_price / context.portfolio.positions_value
    
    if position_percent <  guard_low or position_percent > guard_high: return True
    
    return False

def log_order(context, data, order, security, info):
    '''
    Log an order to the console
    info: Any string to print out
    '''
    if order:
        if get_order(order).amount > 0:
            log.info(str(info) + '  ' + '++++ placing order for ' + str( get_order(order).amount) + '  ' + str(security))
        else:
            log.info(str(info) + '  ' + '---- Exiting Order for ' + str( get_order(order).amount) + '  ' + str(security))  
    else:    #empty order 
         log.info(str(info) + '  ' +  '!!!!! WARNING: Order FAILED for ' + str(security))
    return


def plots(context, data):
    '''
    Custom plot on Quantopian Chart
    '''
    record(leverage=context.account.leverage)
    return

def my_pipeline(context):
    # Custom filter to return only sids in the list
    my_sid_filter = SidInList(
        sid_list = (                                      
            symbol('XLY').sid,  # XLY Consumer Discretionary 
            symbol('XLF').sid,  # XLF Financial              
            symbol('XLK').sid,  # XLK Technology              
            symbol('XLE').sid,  # XLE Energy                   
            symbol('XLV').sid,  # XLV Health Care              
            symbol('XLI').sid,  # XLI Industrial             
            symbol('XLP').sid,  # XLP Consumer Staples         
            symbol('XLB').sid,  # XLB Materials               
            symbol('XLU').sid,  # XLU Utilities              
        )
    )
    
    sector_rank = Returns(
            inputs=[USEquityPricing.close],
            window_length=253,
            mask = my_sid_filter)

    pipe = Pipeline(
            columns = {
                    'sector_rank' : sector_rank,
                    },
            screen = my_sid_filter,
            )
    
    return pipe

class SidInList(CustomFilter):
    """
    Filter returns True for any SID included in parameter tuple passed at creation.
    Usage: my_filter = SidInList(sid_list=(23911, 46631))
    https://www.quantopian.com/posts/using-a-specific-list-of-securities-in-pipeline
    """    
    inputs = []
    window_length = 1
    params = ('sid_list',)

    def compute(self, today, assets, out, sid_list):
        out[:] = np.in1d(assets, sid_list)       
        
        
'''
MIT License

Copyright (c) 2017 John Glossner

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
'''
 
There was a runtime error.