Back to Community
Asset Class Rotation Strategy for Retirement Accounts

Quantopian works great when linked to an IRA or regular brokerage account. But what about my 401k, where most of my investable assets are? I want to implement an "algorithm" to deploy with that money.

Momentum is real phenomenon in the market backed by academic research. What if we look at the momentum of 5 uncorrelated asset classes to determine where we should rebalance our portfolio into? I chose the following 5 asset classes and used the following mutual fund ticker to look at historical performance going back to 1997.

  • Intermediate Treasuries FGOVX
  • S&P 500 VFINX
  • Mid-Cap Value TRMCX
  • International Developed Markets Small Cap VINEX
  • Emerging Markets VEIEX

To select the asset class with the best momentum indicator I calculate the ratio of its 20 day moving average to its 120 day moving average. The asset class with the highest ratio we invest into. Rebalance/check every week. This link provides a plot of the returns: https://engineeredportfolio.files.wordpress.com/2017/02/asset-class-rotation-strategy.jpg

In the attached backtest I built the same strategy in Quantopian using ETFs, specifically:

  • Intermediate Treasuries IEF
  • S&P 500 IVV
  • Mid-Cap Value IJJ
  • International Developed Markets Small Cap SCZ
  • Emerging Markets EEM

I came up with these 5 asset classes after doing backtesting on 40 years of historical data. The data can be found on the bogleheads forum.

Clone Algorithm
121
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
import pandas as pd
import math
import numpy as np
 
def initialize(context):
    """
    Called once at the start of the algorithm.
    """   
    # Rebalance at begining of every month, 1 hour after market open.
    schedule_function(my_rebalance, date_rules.week_start(), time_rules.market_open(hours=1))
     
    # Record tracking variables at the end of each day.
    schedule_function(my_record_vars, date_rules.week_start(), time_rules.market_close())
    """
    context.assets = [sid(21513), #C Fund Equivalent - IVV (ISHARES CORE S&P 500 ETF)
                      sid(21508), #S Fund Equivalent - IJR (ISHARES CORE S&P SMALL-CAP ETF)
                      sid(22972), #I Fund Equivalent - EFA (ISHARES MSCI EAFE ETF)
                      sid(25485), #F Fund Equivalent - AGG (ISHARES CORE U.S. AGGREGATE BOND)
                      sid(23911)] #G Fund Equivalent - SHY (ISHARES 1-3 YEAR TREASURY BOND)
    """
    context.assets = [sid(23870), #IEF (ISHARES 7-10 YEAR TREASURY BOND)
                      sid(21513), #IVV (ISHARES CORE S&P 500)
                      sid(21770), #IJJ (ISHARES S&P MID-CAP 400 VALUE)
                      sid(35248), #SCZ (ISHARES MSCI EAFE SMALL-CAP)
                      sid(24705)] #EEM (ISHARES MSCI EMERGING MARKETS)
    
    context.weights = np.zeros(len(context.assets))
    
    context.short_window = 20
    context.long_window = 120
    context.i = -1
    context.ratio = 0
 
def my_rebalance(context,data):
    """
    Execute orders 
    """
    n = len(context.assets)
    context.weights = np.zeros(len(context.assets))
    
    i = -1
    best = 0
    for x in range(n):
        stock = context.assets[x]
        st = data.history(stock, "price", context.short_window, frequency="1d").mean()
        lt = data.history(stock, "price", context.long_window, frequency="1d").mean()
        returns = st/lt
        if returns > best:
            i = x
            best = returns
            
    if i>=0:        
        context.weights[i] = 1       
    context.i = (float(i)+1)/float(n)
    context.ratio = best
            
    for x in range(n):
        order_target_percent(context.assets[x], context.weights[x])
    
def my_record_vars(context, data):
    """
    Plot variables at the end of each day.
    """
    record(asset_type = context.i, best_ratio = context.ratio)
There was a runtime error.
19 responses

Here's a notebook which you can use to calculate the asset class to allocate your money into. I also included a tear sheet of the above back test.

@Quantopian, it would be nice if we could set up email alerts. I know this has been brought up before but I wanted to reiterate the request.

Loading notebook preview...
Notebook previews are currently unavailable.

This is a great idea, but the performance was great until September 2014. September 2014 to date has underperformed just holding SPY.

Good point Tyler,

This strategy is definitely for folks that can exhibit some patience and not be too concerned with yearly returns. In retirement accounts you can afford to do this where you can't even access the money for 20+ years. But it is a good point, and humans (myself included) can have a hard time sticking with a strategy when it is under performing.

Part of the reason for its under-performance in that time period is how hard hit emerging markets was in 2015 (lost 15% vs a 1% gain for the S&P 500). Emerging markets has big boom years but... it has also has big bust years. If you remove emerging markets as an option in this rotation strategy, the overall returns go down, but its much more consistent. I actually have to do this anyways in my 401k because the emerging markets fund has a 90 day redemption period. I've attached the returns of this version.

Here are the returns going back to 1997: https://engineeredportfolio.files.wordpress.com/2017/02/asset-class-rotation-strategy-no-emerging-markets1.jpg

One other point is that this type of strategy is probably a good idea for some percentage of your retirement account, not the whole thing. I for example have allocated 50% to this, the rest is in a buy-and-hold strategy.

Clone Algorithm
121
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
import pandas as pd
import math
import numpy as np
 
def initialize(context):
    """
    Called once at the start of the algorithm.
    """   
    # Rebalance at begining of every month, 1 hour after market open.
    schedule_function(my_rebalance, date_rules.week_start(), time_rules.market_open(hours=1))
     
    # Record tracking variables at the end of each day.
    schedule_function(my_record_vars, date_rules.week_start(), time_rules.market_close())
    """
    context.assets = [sid(21513), #C Fund Equivalent - IVV (ISHARES CORE S&P 500 ETF)
                      sid(21508), #S Fund Equivalent - IJR (ISHARES CORE S&P SMALL-CAP ETF)
                      sid(22972), #I Fund Equivalent - EFA (ISHARES MSCI EAFE ETF)
                      sid(25485), #F Fund Equivalent - AGG (ISHARES CORE U.S. AGGREGATE BOND)
                      sid(23911)] #G Fund Equivalent - SHY (ISHARES 1-3 YEAR TREASURY BOND)
    """
    context.assets = [sid(23870), #IEF (ISHARES 7-10 YEAR TREASURY BOND)
                      sid(21513), #IVV (ISHARES CORE S&P 500)
                      sid(21770), #IJJ (ISHARES S&P MID-CAP 400 VALUE)
                      sid(35248)] #SCZ (ISHARES MSCI EAFE SMALL-CAP)
    
    context.weights = np.zeros(len(context.assets))
    
    context.short_window = 20
    context.long_window = 120
    context.i = -1
    context.ratio = 0
 
def my_rebalance(context,data):
    """
    Execute orders 
    """
    n = len(context.assets)
    context.weights = np.zeros(len(context.assets))
    
    i = -1
    best = 0
    for x in range(n):
        stock = context.assets[x]
        st = data.history(stock, "price", context.short_window, frequency="1d").mean()
        lt = data.history(stock, "price", context.long_window, frequency="1d").mean()
        returns = st/lt
        if returns > best:
            i = x
            best = returns
            
    if i>=0:        
        context.weights[i] = 1       
    context.i = (float(i)+1)/float(n)
    context.ratio = best
            
    for x in range(n):
        order_target_percent(context.assets[x], context.weights[x])
    
def my_record_vars(context, data):
    """
    Plot variables at the end of each day.
    """
    record(asset_type = context.i, best_ratio = context.ratio)
There was a runtime error.

Finally I'm posting a version for folks that don't have a wide range of asset class options, namely those in a Thrift Savings Plan. The options in this one include:

  • Domestic Large Cap
  • Domestic Small Cap
  • International Developed Market Equities
  • US Aggregate Bond
  • Short Term US Treasuries

This under performs the S&P 500; but it offers a much smoother ride if you're into that sort of thing. You can play around too with removing some of the options (only doing small cap, international, and aggregate bonds for example).

Clone Algorithm
121
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
import pandas as pd
import math
import numpy as np
 
def initialize(context):
    """
    Called once at the start of the algorithm.
    """   
    # Rebalance at begining of every month, 1 hour after market open.
    schedule_function(my_rebalance, date_rules.week_start(), time_rules.market_open(hours=1))
     
    # Record tracking variables at the end of each day.
    schedule_function(my_record_vars, date_rules.week_start(), time_rules.market_close())
    context.assets = [sid(21513), #C Fund Equivalent - IVV (ISHARES CORE S&P 500 ETF)
                      sid(21508), #S Fund Equivalent - IJR (ISHARES CORE S&P SMALL-CAP ETF)
                      sid(22972), #I Fund Equivalent - EFA (ISHARES MSCI EAFE ETF)
                      sid(25485), #F Fund Equivalent - AGG (ISHARES CORE U.S. AGGREGATE BOND)
                      sid(23911)] #G Fund Equivalent - SHY (ISHARES 1-3 YEAR TREASURY BOND)
    """
    context.assets = [sid(23870), #IEF (ISHARES 7-10 YEAR TREASURY BOND)
                      sid(21513), #IVV (ISHARES CORE S&P 500)
                      sid(21770), #IJJ (ISHARES S&P MID-CAP 400 VALUE)
                      sid(35248), #SCZ (ISHARES MSCI EAFE SMALL-CAP)
                      sid(24705)] #EEM (ISHARES MSCI EMERGING MARKETS)
    """
    
    context.weights = np.zeros(len(context.assets))
    
    context.short_window = 20
    context.long_window = 180
    context.i = -1
    context.ratio = 0
 
def my_rebalance(context,data):
    """
    Execute orders 
    """
    n = len(context.assets)
    context.weights = np.zeros(len(context.assets))
    
    i = -1
    best = 0
    for x in range(n):
        stock = context.assets[x]
        st = data.history(stock, "price", context.short_window, frequency="1d").mean()
        lt = data.history(stock, "price", context.long_window, frequency="1d").mean()
        returns = st/lt
        if returns > best:
            i = x
            best = returns
            
    if i>=0:        
        context.weights[i] = 1       
    context.i = (float(i)+1)/float(n)
    context.ratio = best
            
    for x in range(n):
        order_target_percent(context.assets[x], context.weights[x])
    
def my_record_vars(context, data):
    """
    Plot variables at the end of each day.
    """
    record(asset_type = context.i, best_ratio = context.ratio)
There was a runtime error.

Here's a version that rotates between short duration, intermediate, and long term treasury bonds based on momentum between the three. I weight this bond class 80%, then static allocation of 15% to the S&P 500, 5% to EAFE. It rebalances monthly, pretty simple stuff!

This has been a nice steady eddy version with a beta of 0 and would be something to consider for folks concerned with risk.

Clone Algorithm
121
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
import pandas as pd
import math
import numpy as np
 
def initialize(context):
    """
    Called once at the start of the algorithm.
    """   
    # Rebalance at begining of every month, 1 hour after market open.
    schedule_function(my_rebalance, date_rules.month_start(), time_rules.market_open(hours=1))
     
    # Record tracking variables at the end of each day.
    schedule_function(my_record_vars, date_rules.month_start(), time_rules.market_close())
    """
    context.assets = [sid(21513), #C Fund Equivalent - IVV (ISHARES CORE S&P 500 ETF)
                      sid(21508), #S Fund Equivalent - IJR (ISHARES CORE S&P SMALL-CAP ETF)
                      sid(22972), #I Fund Equivalent - EFA (ISHARES MSCI EAFE ETF)
                      sid(25485), #F Fund Equivalent - AGG (ISHARES CORE U.S. AGGREGATE BOND)
                      sid(23911)] #G Fund Equivalent - SHY (ISHARES 1-3 YEAR TREASURY BOND)

    context.assets = [sid(23870), #IEF (ISHARES 7-10 YEAR TREASURY BOND)
                      sid(21513), #IVV (ISHARES CORE S&P 500)
                      sid(21770), #IJJ (ISHARES S&P MID-CAP 400 VALUE)
                      sid(35248), #SCZ (ISHARES MSCI EAFE SMALL-CAP)
                      sid(24705)] #EEM (ISHARES MSCI EMERGING MARKETS)
    """                      
    
    context.assets = [sid(23911), #SHY (ISHARES 1-3 YEAR TREASURY BOND)
                      sid(23870), #IEF (ISHARES 7-10 YEAR TREASURY BOND)
                      sid(23921)] #TLT (ISHARES 20+ YEAR TREASURY BOND
    
    context.static = [sid(21513), #IVV (ISHARES CORE S&P 500)
                      sid(22972)] #EFA (ISHARES MSCI EAFE ETF)  
    context.static_w = [0.15, #IVV (ISHARES CORE S&P 500)
                        0.05] #EFA (ISHARES MSCI EAFE ETF)      
        
    context.weights = np.zeros(len(context.assets))
    
    context.short_window = 20
    context.long_window = 60
    context.i = -1
    context.ratio = 0
 
def my_rebalance(context,data):
    """
    Execute orders 
    """
    n = len(context.assets)
    context.weights = np.zeros(len(context.assets))
    
    i = -1
    best = 0
    for x in range(n):
        stock = context.assets[x]
        st = data.history(stock, "price", context.short_window, frequency="1d").mean()
        lt = data.history(stock, "price", context.long_window, frequency="1d").mean()
        returns = st/lt
        if returns > best:
            i = x
            best = returns
            
    if i>=0:        
        context.weights[i] = 1 - sum(context.static_w) 
    context.i = (float(i)+1)/float(n)
    context.ratio = best
            
    for x in range(n):
        order_target_percent(context.assets[x], context.weights[x])
    for x in range(len(context.static)):
        order_target_percent(context.static[x], context.static_w[x])        
    
def my_record_vars(context, data):
    """
    Plot variables at the end of each day.
    """
    record(asset_type = context.i, best_ratio = context.ratio)
There was a runtime error.

Now here's a more traditional sector rotation strategy paired with the treasury rotation.

For the sectors, it looks at the ratio of the 20 day to the 240 day moving average for all sectors. Then it even weights (12%) the top 5 sectors for a total equity allocation of 60%.

For the treasuries it looks at the 20 to the 60 day ratio and allocates 40% to that bond class.

Clone Algorithm
121
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
import pandas as pd
import math
import numpy as np
 
def initialize(context):
    """
    Called once at the start of the algorithm.
    """   
    # Rebalance at begining of every month, 1 hour after market open.
    schedule_function(my_rebalance, date_rules.month_start(), time_rules.market_open(hours=1))
     
    # Record tracking variables at the end of each day.
    schedule_function(my_record_vars, date_rules.month_start(), time_rules.market_close())
    """
    context.assets = [sid(21513), #C Fund Equivalent - IVV (ISHARES CORE S&P 500 ETF)
                      sid(21508), #S Fund Equivalent - IJR (ISHARES CORE S&P SMALL-CAP ETF)
                      sid(22972), #I Fund Equivalent - EFA (ISHARES MSCI EAFE ETF)
                      sid(25485), #F Fund Equivalent - AGG (ISHARES CORE U.S. AGGREGATE BOND)
                      sid(23911)] #G Fund Equivalent - SHY (ISHARES 1-3 YEAR TREASURY BOND)

    context.assets = [sid(23870), #IEF (ISHARES 7-10 YEAR TREASURY BOND)
                      sid(21513), #IVV (ISHARES CORE S&P 500)
                      sid(21770), #IJJ (ISHARES S&P MID-CAP 400 VALUE)
                      sid(35248), #SCZ (ISHARES MSCI EAFE SMALL-CAP)
                      sid(24705)] #EEM (ISHARES MSCI EMERGING MARKETS)
    context.assets = [#sid(21512), #IVE
               sid(21513), #IVV
               #sid(21514), #IVW
               sid(21770), #IJJ
               #sid(21507), #IJH
               #sid(21771), #IJK
               #sid(21772), #IJS
               #sid(21508), #IJR
               sid(21773)] #IJT       
    """
    context.safe_assets = [sid(23911), #SHY (ISHARES 1-3 YEAR TREASURY BOND)
                           sid(23870), #IEF (ISHARES 7-10 YEAR TREASURY BOND)
                           sid(23921)] #TLT (ISHARES 20+ YEAR TREASURY BOND
    
    context.equity_assets = [sid(19654), #XLB Materials
                             sid(19662), #XLY Consumer Cyclical
                             sid(19656), #XLF Financials
                             sid(21652), #IYR ISHARES Real Estate
                             sid(19659), #XLP Consumer Defensive
                             sid(19661), #XLV Healthcare
                             sid(19660), #XLU Utilities
                             sid(19655), #XLE Energy
                             sid(19657), #XLI Industrials
                             sid(19658)] #XLK Tech
        
    context.safe_i = -1
    context.safe_ratio = 0
    context.equity_ratio = 0
 
def my_rebalance(context,data):
    """
    Determine treasury duration to be in and rebalance 40% to it
    """
    n = len(context.safe_assets)
    weights = np.zeros(n)
    i = -1
    best = 0
    for x in range(n):
        stock = context.safe_assets[x]
        st = data.history(stock, "price", 20, frequency="1d").mean()
        lt = data.history(stock, "price", 60, frequency="1d").mean()
        returns = st/lt
        if returns > best:
            i = x
            best = returns
    weights[i] = 0.4        
    for x in range(n):
        order_target_percent(context.safe_assets[x], weights[x])
    context.safe_i = (float(i)+1.0)/3.0
    context.safe_ratio = best
        
    """
    Determine best 5 sectors and even weight them 12% each
    """        
    n = len(context.equity_assets)
    ratios = np.zeros(n)            
    weights = np.zeros(n)
    for x in range(n):
        stock = context.equity_assets[x]
        st = data.history(stock, "price", 20, frequency="1d").mean()
        lt = data.history(stock, "price", 240, frequency="1d").mean()
        ratios[x] = st/lt        
    context.equity_ratio = np.percentile(ratios, 50)               
    for x in range(n):
        if ratios[x] >= context.equity_ratio:
            order_target_percent(context.equity_assets[x], 0.12)
        else:
            order_target_percent(context.equity_assets[x], 0.0)                 
    
def my_record_vars(context, data):
    """
    Plot variables at the end of each day.
    """
    record(safe_ratio = context.safe_ratio, equity_ratio = context.equity_ratio, safe_type = context.safe_i)
There was a runtime error.

Finally, we'll check the median 20:240 SMA of our sectors, and if it's less than 1 we're going to go full bonds. Otherwise the performance will be the same as above with the 60:40 stocks:bonds.

Clone Algorithm
121
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
import pandas as pd
import math
import numpy as np
 
def initialize(context):
    """
    Called once at the start of the algorithm.
    """   
    # Rebalance at begining of every month, 1 hour after market open.
    schedule_function(my_rebalance, date_rules.month_start(), time_rules.market_open(hours=1))
     
    # Record tracking variables at the end of each day.
    schedule_function(my_record_vars, date_rules.month_start(), time_rules.market_close())
    """
    context.assets = [sid(21513), #C Fund Equivalent - IVV (ISHARES CORE S&P 500 ETF)
                      sid(21508), #S Fund Equivalent - IJR (ISHARES CORE S&P SMALL-CAP ETF)
                      sid(22972), #I Fund Equivalent - EFA (ISHARES MSCI EAFE ETF)
                      sid(25485), #F Fund Equivalent - AGG (ISHARES CORE U.S. AGGREGATE BOND)
                      sid(23911)] #G Fund Equivalent - SHY (ISHARES 1-3 YEAR TREASURY BOND)

    context.assets = [sid(23870), #IEF (ISHARES 7-10 YEAR TREASURY BOND)
                      sid(21513), #IVV (ISHARES CORE S&P 500)
                      sid(21770), #IJJ (ISHARES S&P MID-CAP 400 VALUE)
                      sid(35248), #SCZ (ISHARES MSCI EAFE SMALL-CAP)
                      sid(24705)] #EEM (ISHARES MSCI EMERGING MARKETS)
    context.assets = [#sid(21512), #IVE
               sid(21513), #IVV
               #sid(21514), #IVW
               sid(21770), #IJJ
               #sid(21507), #IJH
               #sid(21771), #IJK
               #sid(21772), #IJS
               #sid(21508), #IJR
               sid(21773)] #IJT       
    """
    context.safe_assets = [sid(23911), #SHY (ISHARES 1-3 YEAR TREASURY BOND)
                           sid(23870), #IEF (ISHARES 7-10 YEAR TREASURY BOND)
                           sid(23921)] #TLT (ISHARES 20+ YEAR TREASURY BOND
    
    context.equity_assets = [sid(19654), #XLB Materials
                             sid(19662), #XLY Consumer Cyclical
                             sid(19656), #XLF Financials
                             sid(21652), #IYR ISHARES Real Estate
                             sid(19659), #XLP Consumer Defensive
                             sid(19661), #XLV Healthcare
                             sid(19660), #XLU Utilities
                             sid(19655), #XLE Energy
                             sid(19657), #XLI Industrials
                             sid(19658)] #XLK Tech
        
    context.safe_i = -1
    context.safe_ratio = 0
    context.equity_ratio = 0
 
def my_rebalance(context,data):
    """
    Determine treasury duration to be in
    """
    n = len(context.safe_assets)
    weights = np.zeros(n)
    i = -1
    best = 0
    for x in range(n):
        stock = context.safe_assets[x]
        st = data.history(stock, "price", 20, frequency="1d").mean()
        lt = data.history(stock, "price", 60, frequency="1d").mean()
        returns = st/lt
        if returns > best:
            i = x
            best = returns
    context.safe_i = (float(i)+1.0)/3.0
    context.safe_ratio = best
        
    """
    Determine best 5 sectors
    """        
    n = len(context.equity_assets)
    ratios = np.zeros(n)         
    for x in range(n):
        stock = context.equity_assets[x]
        st = data.history(stock, "price", 20, frequency="1d").mean()
        lt = data.history(stock, "price", 240, frequency="1d").mean()
        ratios[x] = st/lt        
    context.equity_ratio = np.percentile(ratios, 50)               
    
    """
    Determine weightings, go full on bonds if equities are showing bearish trend
    """
    if context.equity_ratio < 1:
        safe_allocation = 1
        equity_allocation = 0
    else:
        safe_allocation = 0.40
        equity_allocation = 0.12       
    for x in range(n):
        if ratios[x] >= context.equity_ratio:
            order_target_percent(context.equity_assets[x], equity_allocation)
        else:
            order_target_percent(context.equity_assets[x], 0.0)        
    n = len(context.safe_assets)     
    weights[i] = safe_allocation        
    for x in range(n):
        order_target_percent(context.safe_assets[x], weights[x])
    
def my_record_vars(context, data):
    """
    Plot variables at the end of each day.
    """
    record(safe_ratio = context.safe_ratio, equity_ratio = context.equity_ratio, safe_type = context.safe_i)
There was a runtime error.

Stephen, very nice work. Something useful for the majority of folks out there stuck in a 401K with limited asset options.

I also find this same 'top down' approach very helpful in algorithm design for use outside of a 401K. Get the 'feel' of the interactions between the various asset classes using this approach. Get the volatility and drawdown under control through weighting and timing. Then simply add leverage to increase returns. Then start replacing some of the equity ETFs with baskets of actual stocks which mimic the ETFs but hopefully outperform them.

Keep it up!

Did you hard-code the 5 stock basket into the algorithm? I tried changing some of the parameters around, to either make it a basket of 7 or 3 stocks, and the results drove me over leverage. Where is this affected in the algorithm?

Delman,

Good question, and I was admittedly a bit lazy on those last two versions I posted. I had hard coded in a 0.12 weighting to each sector assuming there would be 5. Here I added a line (line 51) in the initialize function to set the allocation to the "safe" asset class. Then the algorithm even weights the equity_assets that are in the top 50th percentile in terms of ratio of short term SMA to long term. And this should be done regardless of the number of assets you are using.

Clone Algorithm
100
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
import pandas as pd
import math
import numpy as np
 
def initialize(context):
    """
    Called once at the start of the algorithm.
    """   
    # Rebalance at begining of every month, 1 hour after market open.
    schedule_function(my_rebalance, date_rules.month_start(), time_rules.market_open(hours=1))
     
    # Record tracking variables at the end of each day.
    schedule_function(my_record_vars, date_rules.month_start(), time_rules.market_close())
    """
    context.assets = [sid(21513), #C Fund Equivalent - IVV (ISHARES CORE S&P 500 ETF)
                      sid(21508), #S Fund Equivalent - IJR (ISHARES CORE S&P SMALL-CAP ETF)
                      sid(22972), #I Fund Equivalent - EFA (ISHARES MSCI EAFE ETF)
                      sid(25485), #F Fund Equivalent - AGG (ISHARES CORE U.S. AGGREGATE BOND)
                      sid(23911)] #G Fund Equivalent - SHY (ISHARES 1-3 YEAR TREASURY BOND)

    context.assets = [sid(23870), #IEF (ISHARES 7-10 YEAR TREASURY BOND)
                      sid(21513), #IVV (ISHARES CORE S&P 500)
                      sid(21770), #IJJ (ISHARES S&P MID-CAP 400 VALUE)
                      sid(35248), #SCZ (ISHARES MSCI EAFE SMALL-CAP)
                      sid(24705)] #EEM (ISHARES MSCI EMERGING MARKETS)
    context.assets = [#sid(21512), #IVE
               sid(21513), #IVV
               #sid(21514), #IVW
               sid(21770), #IJJ
               #sid(21507), #IJH
               #sid(21771), #IJK
               #sid(21772), #IJS
               #sid(21508), #IJR
               sid(21773)] #IJT       
    """
    context.safe_assets = [sid(23911), #SHY (ISHARES 1-3 YEAR TREASURY BOND)
                           sid(23870), #IEF (ISHARES 7-10 YEAR TREASURY BOND)
                           sid(23921)] #TLT (ISHARES 20+ YEAR TREASURY BOND
    
    context.equity_assets = [sid(19654), #XLB Materials
                             sid(19662), #XLY Consumer Cyclical
                             sid(19656), #XLF Financials
                             sid(21652), #IYR ISHARES Real Estate
                             sid(19659), #XLP Consumer Defensive
                             sid(19661), #XLV Healthcare
                             sid(19660), #XLU Utilities
                             sid(19655), #XLE Energy
                             sid(19657), #XLI Industrials
                             sid(19658)] #XLK Tech
        
    context.safe_allocation = 0.4 #allocation to treasuries
    
    context.safe_i = -1
    context.safe_ratio = 0
    context.equity_ratio = 0
 
def my_rebalance(context,data):
    """
    Determine treasury duration to be in and rebalance 40% to it
    """
    n = len(context.safe_assets)
    weights = np.zeros(n)
    i = -1
    best = 0
    for x in range(n):
        stock = context.safe_assets[x]
        st = data.history(stock, "price", 20, frequency="1d").mean()
        lt = data.history(stock, "price", 60, frequency="1d").mean()
        returns = st/lt
        if returns > best:
            i = x
            best = returns
    weights[i] = context.safe_allocation       
    for x in range(n):
        order_target_percent(context.safe_assets[x], weights[x])
    context.safe_i = (float(i)+1.0)/3.0
    context.safe_ratio = best
        
    """
    Determine best 5 sectors and even weight them
    """        
    n = len(context.equity_assets)
    ratios = np.zeros(n)            
    weights = np.zeros(n)
    for x in range(n):
        stock = context.equity_assets[x]
        st = data.history(stock, "price", 20, frequency="1d").mean()
        lt = data.history(stock, "price", 240, frequency="1d").mean()
        ratios[x] = st/lt        
    context.equity_ratio = np.percentile(ratios, 50)     
    num = 0
    for x in range(n):
        if ratios[x] >= context.equity_ratio:
            num += 1
        else:
            order_target_percent(context.equity_assets[x], 0.0)   
    for x in range(n):
        if ratios[x] >= context.equity_ratio:
            order_target_percent(context.equity_assets[x], (1-context.safe_allocation)/num)
    
def my_record_vars(context, data):
    """
    Plot variables at the end of each day.
    """
    record(safe_ratio = context.safe_ratio, equity_ratio = context.equity_ratio, safe_type = context.safe_i)
There was a runtime error.

With the new line added, my understanding is that if you change line 90

context.equity_ratio = np.percentile(ratios, 50)  

to
context.equity_ratio = np.percentile(ratios, 30) it will take the top 30% instead, regardless of how many ETFS are in your basket. Is this correct?

It will actually do the inverse, it will take the top 70% regardless of how many ETFs are in there.

In this version I count how many sector ETFs have a "bearish" trend (defined by their 20:240 day SMA ratio being below 1) to dynamically adjust the bond allocation between 10% and 60%.

I also removed a couple of the sectors (materials, and industrials) because they have never been the best sector for a 3 year period over the last 40 years, indicating that they don't go on sustainable runs of outperformance that we need to be a part of. The analysis and dataset is available on my blog: historical performance of US sectors. And with the removal of these sectors, we only need to hold the top 3 sectors.

Clone Algorithm
100
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
import pandas as pd
import math
import numpy as np
 
def initialize(context):
    """
    Called once at the start of the algorithm.
    """   
    # Rebalance at begining of every month, 1 hour after market open.
    schedule_function(my_rebalance, date_rules.month_start(), time_rules.market_open(hours=1))
     
    # Record tracking variables at the end of each day.
    schedule_function(my_record_vars, date_rules.month_start(), time_rules.market_close())
    """
    context.assets = [sid(21513), #C Fund Equivalent - IVV (ISHARES CORE S&P 500 ETF)
                      sid(21508), #S Fund Equivalent - IJR (ISHARES CORE S&P SMALL-CAP ETF)
                      sid(22972), #I Fund Equivalent - EFA (ISHARES MSCI EAFE ETF)
                      sid(25485), #F Fund Equivalent - AGG (ISHARES CORE U.S. AGGREGATE BOND)
                      sid(23911)] #G Fund Equivalent - SHY (ISHARES 1-3 YEAR TREASURY BOND)

    context.assets = [sid(23870), #IEF (ISHARES 7-10 YEAR TREASURY BOND)
                      sid(21513), #IVV (ISHARES CORE S&P 500)
                      sid(21770), #IJJ (ISHARES S&P MID-CAP 400 VALUE)
                      sid(35248), #SCZ (ISHARES MSCI EAFE SMALL-CAP)
                      sid(24705)] #EEM (ISHARES MSCI EMERGING MARKETS)
    context.assets = [#sid(21512), #IVE
               sid(21513), #IVV
               #sid(21514), #IVW
               sid(21770), #IJJ
               #sid(21507), #IJH
               #sid(21771), #IJK
               #sid(21772), #IJS
               #sid(21508), #IJR
               sid(21773)] #IJT       
    """
    context.safe_assets = [sid(23911), #SHY (ISHARES 1-3 YEAR TREASURY BOND)
                           sid(23870), #IEF (ISHARES 7-10 YEAR TREASURY BOND)
                           sid(23921)] #TLT (ISHARES 20+ YEAR TREASURY BOND
    
    context.equity_assets = [#sid(19654), #XLB Materials
                             sid(19662), #XLY Consumer Cyclical
                             sid(19656), #XLF Financials
                             sid(21652), #IYR ISHARES Real Estate
                             sid(19659), #XLP Consumer Defensive
                             sid(19661), #XLV Healthcare
                             sid(19660), #XLU Utilities
                             sid(19655), #XLE Energy
                             #sid(19657), #XLI Industrials
                             sid(19658)] #XLK Tech
        
    context.min_safe_allocation = 0.1 #minimum allocation to treasuries
    context.max_safe_allocation = 0.6 #maximum allocation to treasuries
    
    context.safe_i = -1
    context.safe_ratio = 0
    context.equity_ratio = 0
 
def my_rebalance(context,data):
    """
    Determine treasury duration to be in
    """
    n = len(context.safe_assets)
    weights = np.zeros(n)
    i = -1
    best = 0
    for x in range(n):
        stock = context.safe_assets[x]
        st = data.history(stock, "price", 20, frequency="1d").mean()
        lt = data.history(stock, "price", 60, frequency="1d").mean()
        returns = st/lt
        if returns > best:
            i = x
            best = returns
        
    """
    Determine best x percentile of sectors
    """        
    n = len(context.equity_assets)
    ratios = np.zeros(n)            
    weights = np.zeros(n)
    for x in range(n):
        stock = context.equity_assets[x]
        st = data.history(stock, "price", 20, frequency="1d").mean()
        lt = data.history(stock, "price", 240, frequency="1d").mean()
        ratios[x] = st/lt        
    context.equity_ratio = np.percentile(ratios, 70)     
    num = 0
    below1 = 0
    for x in range(n):
        if ratios[x] >= context.equity_ratio:
            num += 1
        else:
            order_target_percent(context.equity_assets[x], 0.0)  
        if ratios[x] < 1:
            below1 += 1
    """
    Determine weighting and rebalance
    """
    context.safe_allocation = context.min_safe_allocation + (context.max_safe_allocation-context.min_safe_allocation)*below1/n
    for x in range(n):
        if ratios[x] >= context.equity_ratio:
            order_target_percent(context.equity_assets[x], (1-context.safe_allocation)/num)    
            
    n = len(context.safe_assets)
    weights[i] = context.safe_allocation       
    for x in range(n):
        order_target_percent(context.safe_assets[x], weights[x])
    context.safe_i = (float(i)+1.0)/3.0
    context.safe_ratio = best
    
def my_record_vars(context, data):
    """
    Plot variables at the end of each day.
    """
    record(safe_ratio = context.safe_ratio, equity_ratio = context.equity_ratio, safe_type = context.safe_i, safe_allocation = context.safe_allocation)
There was a runtime error.

I wanted to circle back to the original point of this post: a rotation strategy you can use in your retirement account. I had tried to implement the strategy but... my 401k plan started flagging me for "excessive" trading. So I went back to the drawing board and tried to find a version that works with only quarterly rebalancing. To determine our funds I'm looking at the ratio of the 20 day simple moving average to the 60 day moving average. The top ratio gets a 50% allocation, the second gets a 30% allocation, and the 3rd best ratio gets a 20% allocation. Rebalance every quarter.

Clone Algorithm
100
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
import pandas as pd
import math
import numpy as np
 
def initialize(context):
    """
    Called once at the start of the algorithm.
    """   
    set_commission(commission.PerTrade(cost=0.00))
    set_commission(commission.PerShare(cost=0.00, min_trade_cost=0.00))
    
    # Rebalance at begining of every month, 1 hour after market open.
    schedule_function(my_rebalance, date_rules.month_start(), time_rules.market_open(hours=1))
     
    # Record tracking variables at the end of each day.
    schedule_function(my_record_vars, date_rules.every_day(), time_rules.market_open())
    """
    context.assets = [sid(21513), #C Fund Equivalent - IVV (ISHARES CORE S&P 500 ETF)
                      sid(21508), #S Fund Equivalent - IJR (ISHARES CORE S&P SMALL-CAP ETF)
                      sid(22972), #I Fund Equivalent - EFA (ISHARES MSCI EAFE ETF)
                      sid(25485), #F Fund Equivalent - AGG (ISHARES CORE U.S. AGGREGATE BOND)
                      sid(23911)] #G Fund Equivalent - SHY (ISHARES 1-3 YEAR TREASURY BOND)
    """
    context.assets = [sid(23921), #TLT (ISHARES 20+ YEAR TREASURY BOND
                      sid(19659), #XLP
                      sid(21770), #IJJ (ISHARES S&P MID-CAP 400 VALUE)
                      #sid(35248), #SCZ (ISHARES MSCI EAFE SMALL-CAP)  
                      sid(22972), #EFA (ISHARES MSCI EAFE ETF)
                      sid(24705)] #EEM (ISHARES MSCI EMERGING MARKETS)
      
    context.ratios = np.zeros(len(context.assets))
    
    context.weights = [0.5, 0.3, 0.2, 0, 0]
    context.month = 3
 
def my_rebalance(context,data):
    n = len(context.assets)
    if context.month < 3:
        context.month += 1
        return 
    context.month = 1
    
    df = pd.DataFrame({'Stock': context.assets,
                       'Ratio': context.ratios})
    df = df.sort_values(by='Ratio', ascending = False)
    stocks = df['Stock'].values.tolist()
        
    for x in range(n):
        order_target_percent(stocks[x], context.weights[x])

                
def my_record_vars(context, data):
    """
    Plot variables at the beginning of each day.
    """
    n = len(context.assets)
    for x in range(n):
        stock = context.assets[x]
        st = data.history(stock, "price", 20, frequency="1d").mean()
        lt = data.history(stock, "price", 60, frequency="1d").mean()
        context.ratios[x] = st/lt        
        
    record(TLT = context.ratios[0], XLP = context.ratios[1], IJJ = context.ratios[2], EFA = context.ratios[3], EEM = context.ratios[4])
There was a runtime error.

Here's a version that can be used for folks in a thrift savings plan (limited/basic options).

Clone Algorithm
100
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
import pandas as pd
import math
import numpy as np
 
def initialize(context):
    """
    Called once at the start of the algorithm.
    """   
    set_commission(commission.PerTrade(cost=0.00))
    set_commission(commission.PerShare(cost=0.00, min_trade_cost=0.00))
    
    # Rebalance at begining of every month, 1 hour after market open.
    schedule_function(my_rebalance, date_rules.month_start(), time_rules.market_open(hours=1))
     
    # Record tracking variables at the end of each day.
    schedule_function(my_record_vars, date_rules.every_day(), time_rules.market_open())
    
    context.assets = [sid(21513), #C Fund Equivalent - IVV (ISHARES CORE S&P 500 ETF)
                      sid(21508), #S Fund Equivalent - IJR (ISHARES CORE S&P SMALL-CAP ETF)
                      sid(22972), #I Fund Equivalent - EFA (ISHARES MSCI EAFE ETF)
                      sid(25485), #F Fund Equivalent - AGG (ISHARES CORE U.S. AGGREGATE BOND)
                      sid(23911)] #G Fund Equivalent - SHY (ISHARES 1-3 YEAR TREASURY BOND)
      
    context.ratios = np.zeros(len(context.assets))
    
    context.weights = [0.5, 0.25, 0.25, 0, 0]
    context.month = 3
 
def my_rebalance(context,data):
    n = len(context.assets)
    if context.month < 3:
        context.month += 1
        return 
    context.month = 1
    
    df = pd.DataFrame({'Stock': context.assets,
                       'Ratio': context.ratios})
    df = df.sort_values(by='Ratio', ascending = False)
    stocks = df['Stock'].values.tolist()
        
    for x in range(n):
        order_target_percent(stocks[x], context.weights[x])

                
def my_record_vars(context, data):
    """
    Plot variables at the beginning of each day.
    """
    n = len(context.assets)
    for x in range(n):
        stock = context.assets[x]
        st = data.history(stock, "price", 20, frequency="1d").mean()
        lt = data.history(stock, "price", 60, frequency="1d").mean()
        context.ratios[x] = st/lt        
        
    record(IVV = context.ratios[0], IJR = context.ratios[1], EFA = context.ratios[2], AGG = context.ratios[3], SHY = context.ratios[4])
There was a runtime error.

Stephen,

Make sure that quarterly rebalancing results heavily depend from date you start it.
Metrics of following backtest which I started 2 month earlier than you not as bright as in original.

Clone Algorithm
2
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
import pandas as pd
import math
import numpy as np
 
def initialize(context):
    """
    Called once at the start of the algorithm.
    """   
    set_commission(commission.PerTrade(cost=0.00))
    set_commission(commission.PerShare(cost=0.00, min_trade_cost=0.00))
    
    # Rebalance at begining of every month, 1 hour after market open.
    schedule_function(my_rebalance, date_rules.month_start(), time_rules.market_open(hours=1))
     
    # Record tracking variables at the end of each day.
    schedule_function(my_record_vars, date_rules.every_day(), time_rules.market_open())
    """
    context.assets = [sid(21513), #C Fund Equivalent - IVV (ISHARES CORE S&P 500 ETF)
                      sid(21508), #S Fund Equivalent - IJR (ISHARES CORE S&P SMALL-CAP ETF)
                      sid(22972), #I Fund Equivalent - EFA (ISHARES MSCI EAFE ETF)
                      sid(25485), #F Fund Equivalent - AGG (ISHARES CORE U.S. AGGREGATE BOND)
                      sid(23911)] #G Fund Equivalent - SHY (ISHARES 1-3 YEAR TREASURY BOND)
    """
    context.assets = [sid(23921), #TLT (ISHARES 20+ YEAR TREASURY BOND
                      sid(19659), #XLP
                      sid(21770), #IJJ (ISHARES S&P MID-CAP 400 VALUE)
                      #sid(35248), #SCZ (ISHARES MSCI EAFE SMALL-CAP)  
                      sid(22972), #EFA (ISHARES MSCI EAFE ETF)
                      sid(24705)] #EEM (ISHARES MSCI EMERGING MARKETS)
      
    context.ratios = np.zeros(len(context.assets))
    
    context.weights = [0.5, 0.3, 0.2, 0, 0]
    context.month = 3
 
def my_rebalance(context,data):
    n = len(context.assets)
    if context.month < 3:
        context.month += 1
        return 
    context.month = 1
    
    df = pd.DataFrame({'Stock': context.assets,
                       'Ratio': context.ratios})
    df = df.sort_values(by='Ratio', ascending = False)
    stocks = df['Stock'].values.tolist()
        
    for x in range(n):
        order_target_percent(stocks[x], context.weights[x])

                
def my_record_vars(context, data):
    """
    Plot variables at the beginning of each day.
    """
    n = len(context.assets)
    for x in range(n):
        stock = context.assets[x]
        st = data.history(stock, "price", 20, frequency="1d").mean()
        lt = data.history(stock, "price", 60, frequency="1d").mean()
        context.ratios[x] = st/lt        
        
    record(TLT = context.ratios[0], XLP = context.ratios[1], IJJ = context.ratios[2], EFA = context.ratios[3], EEM = context.ratios[4])
There was a runtime error.

Thanks Vladimir,

It's a good point; maybe I got a bit lucky with how long term treasuries were moving out-of-phase with equities perfectly at quarterly starts/ends.

Here's a cleaner version which looks at the 20:200 day moving average to smooth the timing element. I also dynamically adjust the bond allocation based on sector ETFs bullish/bearish trends (the more sectors with 20 day above their respective 200 day SMA = the less bonds we own). This seems to be less dependent on start date.

Clone Algorithm
100
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
import pandas as pd
import math
import numpy as np
 
def initialize(context):
    """
    Called once at the start of the algorithm.
    """   
    set_commission(commission.PerTrade(cost=0.00))
    set_commission(commission.PerShare(cost=0.00, min_trade_cost=0.00))
    
    # Rebalance at begining of every month, 1 hour after market open.
    schedule_function(my_rebalance, date_rules.month_start(), time_rules.market_open(hours=1))
     
    # Record tracking variables at the end of each day.
    schedule_function(my_record_vars, date_rules.every_day(), time_rules.market_open())
    """
    context.assets = [sid(21513), #C Fund Equivalent - IVV (ISHARES CORE S&P 500 ETF)
                      sid(21508), #S Fund Equivalent - IJR (ISHARES CORE S&P SMALL-CAP ETF)
                      sid(22972), #I Fund Equivalent - EFA (ISHARES MSCI EAFE ETF)
                      sid(25485), #F Fund Equivalent - AGG (ISHARES CORE U.S. AGGREGATE BOND)
                      sid(23911)] #G Fund Equivalent - SHY (ISHARES 1-3 YEAR TREASURY BOND)
    """
    context.safe = sid(23921) #TLT (ISHARES 20+ YEAR TREASURY BOND
    context.safe_max = 0.6 #Max weighting to "safe" asset
    
    context.assets = [sid(19659), #XLP (CONSUMER STAPLES SELECT SECTOR SPDR FUND)
                      sid(21770), #IJJ (ISHARES S&P MID-CAP 400 VALUE)
                      #sid(35248), #SCZ (ISHARES MSCI EAFE SMALL-CAP)  
                      sid(22972), #EFA (ISHARES MSCI EAFE ETF)
                      sid(24705)] #EEM (ISHARES MSCI EMERGING MARKETS)
    context.weights = [0.5, 0.3, 0.2, 0.0]
      
    context.ratios = np.zeros(len(context.assets))
    
    context.month = 3
    context.bond = 0
 
def my_rebalance(context,data):
    if context.month < 3:
        context.month += 1
        return 
    context.month = 1    
    
    sectors = [sid(19654), #XLB Materials
               sid(19662), #XLY Consumer Cyclical
               sid(19656), #XLF Financials
               sid(21652), #IYR ISHARES Real Estate
               sid(19659), #XLP Consumer Defensive
               sid(19661), #XLV Healthcare
               sid(19660), #XLU Utilities
               sid(19655), #XLE Energy
               sid(19657), #XLI Industrials
               sid(19658)] #XLK Tech

    n = len(sectors)
    below1 = 0
    for x in range(n):
        stock = sectors[x]
        st = data.history(stock, "price", 20, frequency="1d").mean()
        lt = data.history(stock, "price", 200, frequency="1d").mean()
        if st/lt < 1:
            below1 += 1   
    context.bond = float(below1)/float(n)*context.safe_max
    
    df = pd.DataFrame({'Stock': context.assets,
                       'Ratio': context.ratios})
    df = df.sort_values(by='Ratio', ascending = False)
    stocks = df['Stock'].values.tolist()
    n = len(context.assets)
    for x in range(n):
        order_target_percent(stocks[x], context.weights[x]*(1-context.bond))
    order_target_percent(context.safe, context.bond)
                
def my_record_vars(context, data):
    """
    Plot variables at the beginning of each day.
    """
    n = len(context.assets)
    for x in range(n):
        stock = context.assets[x]
        st = data.history(stock, "price", 20, frequency="1d").mean()
        lt = data.history(stock, "price", 240, frequency="1d").mean()
        context.ratios[x] = st/lt        
    #record(TLT = context.ratios[0], XLP = context.ratios[1], IJJ = context.ratios[2], SCZ = context.ratios[3], EEM = context.ratios[4])
    record(Bond = context.bond, XLP = context.ratios[0], IJJ = context.ratios[1], EFA = context.ratios[2], EEM = context.ratios[3])
There was a runtime error.

Any new progress or developments/ideas for this algorithm?

2017 has been pretty productive for this type of asset class momentum strategy - the year started out with international equities having higher momentum signals and they have continued to outperform throughout the year. My 401k that used a similar strategy to the original post is up 15% YTD. But... I've grown weary of the strategy for a couple reasons:

  • I'm nervous that it relies too much on treasuries moving out of phase with equities and on treasury upside performance during market downturns. I think the fed may have made treasuries look like better investments over the past 20 years than what we can expect over the next 20 years.
  • I've learned from live trading a couple strategies in a Robinhood account that a 14 year plot/backtest doesn't adequately describe what the investor has to feel on a day-to-day, even month-to-month basis. These momentum only indicator based strategies can be highly volatile and difficult to stick with.
  • I had spent a lot of work on dynamic asset allocation and wanted to adapt that for a 401k strategy.

So in this attached backtest I only use the 5 asset classes in a thrift savings plan, virtually any/every 401k should have these:

  • S&P500
  • EAFE stocks
  • US small cap stocks
  • Total US bond
  • Short term treasuries.

It dynamically solves for the most efficient allocation between the three stock assets, and it counts the number of bearish sectors to determine how much bonds to hold. The code currently allocates 20% bonds per bearish sector but you can obviously play with that. Within bonds it picks the asset class that has the highest 20:60 day momentum. Rebalances monthly like you can do in a 401k.

Clone Algorithm
50
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
import pandas as pd
import math
import numpy as np
import itertools
 
def initialize(context):
    """
    Called once at the start of the algorithm.
    """   
    set_commission(commission.PerTrade(cost=0))
    
    schedule_function(my_rebalance, date_rules.month_start(), time_rules.market_open(hours=2))
    schedule_function(record_metrics, date_rules.month_start(), time_rules.market_open(hours=1))
    
    context.assets = [sid(8554), #SPY
                      sid(22972), #EFA
                      sid(21508)] #IJR
    a = [0,.2,.4,.6,.8,1.0]
    context.port = create_portfolios([a,a,a]) 
    context.weight = np.zeros(len(context.assets))
    
    context.interval = 1
    
    context.bonds = [sid(25485), #AGG
                     sid(23911)] #SHY
    
    context.sectors = [sid(19654), #XLB Materials
                       sid(19662), #XLY Consumer Cyclical
                       sid(19656), #XLF Financials
                       sid(21652), #IYR ISHARES Real Estate
                       sid(19659), #XLP Consumer Defensive
                       sid(19661), #XLV Healthcare
                       sid(19660), #XLU Utilities
                       sid(19655), #XLE Energy
                       sid(19657), #XLI Industrials
                       sid(19658)] #XLK Tech
    context.sector_step = 0.2
    
    context.bonds_data = pd.DataFrame(columns=['Weight','Ratio','20Day','60Day']) 
    context.sectors_data = pd.DataFrame(columns=['Ratio','20Day','200Day']) 
    context.started = 0
    
    context.fixed = [sid(24705)] #EEM
    context.fixed_weight = [0.0]
    
def create_portfolios(a):
    """
    Create portfolio combinations
    """
    b = list(itertools.product(*a))
    x = [sum(i) for i in b]
    port = pd.DataFrame(b)
    port['Sum'] = x
    port = port[port.Sum == 1]
    del port['Sum']
    return port
 
def my_rebalance(context,data):    
    if context.interval < 1:
        context.interval += 1
        return 
    context.interval = 1
    
    n = len(context.assets)
    for x in range(n):
        order_target_percent(context.assets[x], context.weight[x])
    for stock in context.bonds:
        order_target_percent(stock, context.bonds_data.loc[stock, 'Weight'])   
    for x in range(len(context.fixed)):
        order_target_percent(context.fixed[x],context.fixed_weight[x])
        
    context.started = 1        
        
def record_metrics(context,data):    
    """
    Determine sector trends and calculate weight to assets/bonds
    """    
    for stock in context.sectors:
        context.sectors_data.loc[stock, '20Day'] = data.history(stock, "price", 20, frequency="1d").mean()
        context.sectors_data.loc[stock, '200Day'] = data.history(stock, "price", 200, frequency="1d").mean()
    context.sectors_data['Ratio'] = context.sectors_data['20Day']/context.sectors_data['200Day'] - 1
    log.info(context.sectors_data)
    
    context.bonds_weight = len(context.sectors_data[context.sectors_data['Ratio'] < 0]) * context.sector_step 
    if context.bonds_weight > 1.0:
        context.bonds_weight = 1.0
    context.bonds_weight = context.bonds_weight * (1-sum(context.fixed_weight))
    
    """
    Determine bond trends and which duration to be in
    """
    for stock in context.bonds:
        context.bonds_data.loc[stock, '20Day'] = data.history(stock, "price", 20, frequency="1d").mean()
        context.bonds_data.loc[stock, '60Day'] = data.history(stock, "price", 60, frequency="1d").mean()
    context.bonds_data['Ratio'] = context.bonds_data['20Day']/context.bonds_data['60Day'] - 1
    context.bonds_data['Weight'] = 0
    context.bonds_data.loc[context.bonds_data['Ratio'].idxmax(), 'Weight'] = context.bonds_weight
    log.info(context.bonds_data)
    
    
    returns = data.history(context.assets, "price", 126, "1d").dropna().pct_change().dropna() + 1.0
    
    port_returns = pd.DataFrame(np.dot(returns, context.port.T), index=returns.index)    
    port_metrics = get_specs(port_returns)
    
    port_metrics = score(port_metrics)
    port_metrics = weight_score(port_metrics,1.0,1.0,'Z3')
    
    portfolios = context.port
    portfolios.columns = list(returns.columns.values)
    best = pd.concat([pd.DataFrame(portfolios.iloc[port_metrics['Z3'].idxmax()]).T])
    log.info(best.loc[:, (best != 0).any(axis=0)].T)
    best = pd.DataFrame(portfolios.iloc[port_metrics['Z3'].idxmax()])
    #log.info(best)
    context.weight = best.values*(1-context.bonds_weight-sum(context.fixed_weight))  
   
    record(spy = context.weight[0],
           international = context.weight[1],
           small = context.weight[2],
           bonds_weight = context.bonds_weight,
           leverage = context.account.leverage)

    if context.started == 0:
        my_rebalance(context,data)        
        
def drawdown(returns):
    mat = returns.cumprod().values
    [n, m] = np.shape(mat)
    maxes = np.maximum.accumulate(np.array(mat))
    for i in range(0,n):
        for j in range(m):
            mat[i,j] = mat[i,j] / maxes[i,j]
    df = pd.DataFrame(mat)
    df[df > 1] = 1
    return df

def moving_returns(returns,w):
    mat = returns.values
    [n, m] = np.shape(mat)
    ret = np.zeros(shape = (n-w+1,m))
    for i in range(w-1,n):
        for j in range(m):
            ret[i-w+1,j] = np.power(np.prod(mat[(i-w+1):i+1,j]),(1.0/w))- 1.0
    return pd.DataFrame(ret)

def get_specs(returns):
    metrics = pd.DataFrame((returns.mean()),columns=['Mean']) - 1.0
    metrics['STD'] = pd.DataFrame((returns.std()))
    metrics['Annualized'] = np.power(returns.cumprod().values.tolist()[-1],1.0/len(returns))- 1.0
    
    downside = returns.copy(deep=True) - 1
    downside[downside > 0] = 0
    downside = downside ** 2
    metrics['DownSide'] = pd.DataFrame(downside.mean() ** 0.5)
    
    draw = drawdown(returns)
    metrics['Max_Draw'] = 1.0 - draw.min().values
    ret15 = moving_returns(returns,21)
    metrics['Minimum15'] = ret15.min().values
    
    ret10 = moving_returns(returns,21)
    metrics['Mean10'] = ret10.mean().values
    metrics['STD10'] = ret10.std().values
    
    return metrics

def zscore(stocks, var, var_save):
    stocks[var_save] = (stocks[var] - stocks[var].mean())/stocks[var].std(ddof=0)
    return stocks 

def score(metrics):
    metrics = zscore(metrics, 'Mean', 'ZMean')
    metrics = zscore(metrics, 'STD', 'ZSTD')
    metrics = zscore(metrics, 'Annualized', 'ZAnnualized')
    metrics = zscore(metrics, 'DownSide', 'ZDownSide')
    metrics = zscore(metrics, 'Max_Draw', 'ZMax_Draw')
    metrics = zscore(metrics, 'Minimum15', 'ZMinimum15')
    metrics = zscore(metrics, 'STD10', 'ZSTD10')
    metrics = zscore(metrics, 'Mean10', 'ZMean10')
    return metrics

def weight_score(metrics,o,d,name):
    metrics[name] = metrics.ZMean*o + metrics.ZSTD*-d + metrics.ZDownSide*-d + metrics.ZAnnualized*o + metrics.ZMax_Draw*-d + metrics.ZSTD10*-d + metrics.ZMean10*o + metrics.ZMinimum15*o 
    return metrics.sort_values(by=name, ascending=False)
There was a runtime error.

Here's what I am actually running in my 401k that has access to some of my favorite asset classes:

  • Mid-Cap value
  • Consumer Staples
  • Small Cap ex-US
  • Long Term Treasuries
  • TIPS

It's the same general logic though.

Clone Algorithm
50
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
import pandas as pd
import math
import numpy as np
import itertools
 
def initialize(context):
    """
    Called once at the start of the algorithm.
    """   
    set_commission(commission.PerTrade(cost=0))
    
    schedule_function(my_rebalance, date_rules.month_start(), time_rules.market_open(hours=2))
    schedule_function(record_metrics, date_rules.month_start(), time_rules.market_open(hours=1))
    
    context.assets = [sid(32521), #VOE
                      sid(25903), #VDC
                      sid(38272)] #VSS
    a = [0,.2,.4,.6,.8,1.0]
    context.port = create_portfolios([a,a,a]) 
    context.weight = np.zeros(len(context.assets))
    
    context.interval = 1
    
    context.bonds = [sid(23921), #TLT
                     sid(25801), #TIP
                     sid(23911)] #SHY
    
    context.sectors = [sid(19654), #XLB Materials
                       sid(19662), #XLY Consumer Cyclical
                       sid(19656), #XLF Financials
                       sid(21652), #IYR ISHARES Real Estate
                       sid(19659), #XLP Consumer Defensive
                       sid(19661), #XLV Healthcare
                       sid(19660), #XLU Utilities
                       sid(19655), #XLE Energy
                       sid(19657), #XLI Industrials
                       sid(19658)] #XLK Tech
    context.sector_step = 0.2
    
    context.bonds_data = pd.DataFrame(columns=['Weight','Ratio','20Day','60Day']) 
    context.sectors_data = pd.DataFrame(columns=['Ratio','20Day','200Day']) 
    context.started = 0
    
    context.fixed = [sid(27102)] #VWO
    context.fixed_weight = [0.]
    
def create_portfolios(a):
    """
    Create portfolio combinations
    """
    b = list(itertools.product(*a))
    x = [sum(i) for i in b]
    port = pd.DataFrame(b)
    port['Sum'] = x
    port = port[port.Sum == 1]
    del port['Sum']
    return port
 
def my_rebalance(context,data):    
    if context.interval < 1:
        context.interval += 1
        return 
    context.interval = 1
    
    n = len(context.assets)
    for x in range(n):
        order_target_percent(context.assets[x], context.weight[x])
    for stock in context.bonds:
        order_target_percent(stock, context.bonds_data.loc[stock, 'Weight'])   
    for x in range(len(context.fixed)):
        order_target_percent(context.fixed[x],context.fixed_weight[x])
        
    context.started = 1        
        
def record_metrics(context,data):    
    """
    Determine sector trends and calculate weight to assets/bonds
    """    
    for stock in context.sectors:
        context.sectors_data.loc[stock, '20Day'] = data.history(stock, "price", 20, frequency="1d").mean()
        context.sectors_data.loc[stock, '200Day'] = data.history(stock, "price", 200, frequency="1d").mean()
    context.sectors_data['Ratio'] = context.sectors_data['20Day']/context.sectors_data['200Day'] - 1
    log.info(context.sectors_data)
    
    context.bonds_weight = len(context.sectors_data[context.sectors_data['Ratio'] < 0]) * context.sector_step 
    if context.bonds_weight > 1.0:
        context.bonds_weight = 1.0
    context.bonds_weight = context.bonds_weight * (1-sum(context.fixed_weight))
    
    """
    Determine bond trends and which duration to be in
    """
    for stock in context.bonds:
        context.bonds_data.loc[stock, '20Day'] = data.history(stock, "price", 20, frequency="1d").mean()
        context.bonds_data.loc[stock, '60Day'] = data.history(stock, "price", 60, frequency="1d").mean()
    context.bonds_data['Ratio'] = context.bonds_data['20Day']/context.bonds_data['60Day'] - 1
    context.bonds_data['Weight'] = 0
    context.bonds_data.loc[context.bonds_data['Ratio'].idxmax(), 'Weight'] = context.bonds_weight
    log.info(context.bonds_data)
    
    
    returns = data.history(context.assets, "price", 126, "1d").dropna().pct_change().dropna() + 1.0
    
    port_returns = pd.DataFrame(np.dot(returns, context.port.T), index=returns.index)    
    port_metrics = get_specs(port_returns)
    
    port_metrics = score(port_metrics)
    port_metrics = weight_score(port_metrics,1.0,1.0,'Z3')
    
    portfolios = context.port
    portfolios.columns = list(returns.columns.values)
    best = pd.concat([pd.DataFrame(portfolios.iloc[port_metrics['Z3'].idxmax()]).T])
    log.info(best.loc[:, (best != 0).any(axis=0)].T)
    best = pd.DataFrame(portfolios.iloc[port_metrics['Z3'].idxmax()])
    #log.info(best)
    context.weight = best.values*(1-context.bonds_weight-sum(context.fixed_weight))  
   
    record(#US = weight[0],
           mid_value = context.weight[0],
           cons = context.weight[1],
           international = context.weight[2],
           bonds_weight = context.bonds_weight,
           leverage = context.account.leverage)

    if context.started == 0:
        my_rebalance(context,data)        
        
def drawdown(returns):
    mat = returns.cumprod().values
    [n, m] = np.shape(mat)
    maxes = np.maximum.accumulate(np.array(mat))
    for i in range(0,n):
        for j in range(m):
            mat[i,j] = mat[i,j] / maxes[i,j]
    df = pd.DataFrame(mat)
    df[df > 1] = 1
    return df

def moving_returns(returns,w):
    mat = returns.values
    [n, m] = np.shape(mat)
    ret = np.zeros(shape = (n-w+1,m))
    for i in range(w-1,n):
        for j in range(m):
            ret[i-w+1,j] = np.power(np.prod(mat[(i-w+1):i+1,j]),(1.0/w))- 1.0
    return pd.DataFrame(ret)

def get_specs(returns):
    metrics = pd.DataFrame((returns.mean()),columns=['Mean']) - 1.0
    metrics['STD'] = pd.DataFrame((returns.std()))
    metrics['Annualized'] = np.power(returns.cumprod().values.tolist()[-1],1.0/len(returns))- 1.0
    
    downside = returns.copy(deep=True) - 1
    downside[downside > 0] = 0
    downside = downside ** 2
    metrics['DownSide'] = pd.DataFrame(downside.mean() ** 0.5)
    
    draw = drawdown(returns)
    metrics['Max_Draw'] = 1.0 - draw.min().values
    ret15 = moving_returns(returns,21)
    metrics['Minimum15'] = ret15.min().values
    
    ret10 = moving_returns(returns,21)
    metrics['Mean10'] = ret10.mean().values
    metrics['STD10'] = ret10.std().values
    
    return metrics

def zscore(stocks, var, var_save):
    stocks[var_save] = (stocks[var] - stocks[var].mean())/stocks[var].std(ddof=0)
    return stocks 

def score(metrics):
    metrics = zscore(metrics, 'Mean', 'ZMean')
    metrics = zscore(metrics, 'STD', 'ZSTD')
    metrics = zscore(metrics, 'Annualized', 'ZAnnualized')
    metrics = zscore(metrics, 'DownSide', 'ZDownSide')
    metrics = zscore(metrics, 'Max_Draw', 'ZMax_Draw')
    metrics = zscore(metrics, 'Minimum15', 'ZMinimum15')
    metrics = zscore(metrics, 'STD10', 'ZSTD10')
    metrics = zscore(metrics, 'Mean10', 'ZMean10')
    return metrics

def weight_score(metrics,o,d,name):
    metrics[name] = metrics.ZMean*o + metrics.ZSTD*-d + metrics.ZDownSide*-d + metrics.ZAnnualized*o + metrics.ZMax_Draw*-d + metrics.ZSTD10*-d + metrics.ZMean10*o + metrics.ZMinimum15*o 
    return metrics.sort_values(by=name, ascending=False)
There was a runtime error.