Back to Community
Enhanced Dual Momentum strategy using quarterly rotation

Securities basket used: US Equities (SPY), Int'l Equities (EFA), Emerging Markets (EEM), Gold (GLD), Corporate Bonds (AGG) and Long-Term Treasury (TLT). This current model holds up to 4 equal-weight positions.

This algorithm essentially monitors dual momentum during the last month of each quarter. These periods coincide with heavy institutional re-balancing and reporting activities, which in theory could provide information on what securities institutions are getting out of (underperformers) and what securities will be chosen by them for the coming quarter (outperformers).

More on this anomaly here:
http://blog.alphaarchitect.com/2015/11/30/momentum-seasonality/#gs.=t85MZQ

https://seekingalpha.com/instablog/709762-varan/251242-a-low-drawdown-strategy-for-sector-rotation-for-fidelity-select-funds

https://www.r-bloggers.com/the-quarterly-tactical-strategy-aka-qts/

Here's a backtest using PortfolioVisualizer that uses mutual funds and goes back to the 90s:
http://bit.ly/2vUKlGM

Have fun! Would love to see how this can be further improved. For collaboration requests, please contact: [email protected] and [email protected]

Shout out to the homie Mohammed Khalfan for helping me code this algo!

Clone Algorithm
764
Loading...
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
#DUAL MOMENTUM QUARTERLY ROTATION
#Developers: Kory Hoang & Mohammed Khalfan
#For collaboration requests, please email: [email protected]

def initialize(context):
    context.currently_holding = []
    schedule_function(clear, date_rules.month_end(), time_rules.market_open())
    schedule_function(rebalance, date_rules.month_end(), time_rules.market_close(minutes=5))
    schedule_function(record_vars, date_rules.every_day(), time_rules.market_close())
    set_benchmark(symbol('SPY'))
    
def clear(context, data):

    today = get_datetime()
    if not (today.month == 12 or today.month == 3 or today.month == 6 or today.month == 9):
        return
    print("1" + str(context.currently_holding))
    for s in context.currently_holding:
        order_target_percent(s, 0)
    context.currently_holding = []   

def rebalance(context, data):
    today = get_datetime()
    if not (today.month == 12 or today.month == 3 or today.month == 6 or today.month == 9):
        return
    
    assets = symbols('SPY','EFA','EEM','GLD')
    safe = symbol('AGG')
    tlt = symbol('TLT')
    
    mom_period = 22 #22 days = 1 month 

    hist = data.history(assets, 'price', mom_period + 1, '1d')  
    mom = (hist.iloc[-1]/hist.iloc[0]) - 1.0  
    mom = mom.dropna()  
    top_assets = mom.sort_values().index[-4:]
    top_assets_values = mom.sort_values()[-4:]
    
    safe_percent = 0
    tlt_percent = 0
    
    for x in range(len(top_assets)):
        if top_assets_values[x] > 0:
            if data.can_trade(top_assets[x]):  
                order_target_percent(top_assets[x], 0.25)
                context.currently_holding.append(top_assets[x])
        else:
            hist = data.history(safe, 'price', mom_period + 1, '1d')  
            mom = (hist[-1]/hist[0]) - 1.0
            if mom > 0:
                safe_percent += 0.25
                if safe not in context.currently_holding: context.currently_holding.append(safe)
            else:
                tlt_percent += 0.25
                if tlt not in context.currently_holding: context.currently_holding.append(tlt)
                    
    top_up = 4 - len(top_assets)
    if top_up > 0:
        for x in range(top_up):
            hist = data.history(safe, 'price', mom_period + 1, '1d')  
            mom = (hist[-1]/hist[0]) - 1.0
            if mom > 0:
                safe_percent += 0.25
                if safe not in context.currently_holding: context.currently_holding.append(safe)
            else:
                tlt_percent += 0.25
                if tlt not in context.currently_holding: context.currently_holding.append(tlt)
    print(safe_percent, tlt_percent)
    if safe_percent > 0.0 and data.can_trade(safe):  
        order_target_percent(safe, safe_percent)                        
                        
    if tlt_percent > 0.0 and data.can_trade(tlt):  
        order_target_percent(tlt, tlt_percent)
        
    print("2" + str(context.currently_holding)  )
def record_vars(context, data):
    record(leverage = context.account.leverage)
There was a runtime error.
16 responses

Here's a "crazy" version of this algo using leveraged ETFs.

Clone Algorithm
764
Loading...
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
#DUAL MOMENTUM QUARTERLY ROTATION
#Developers: Kory Hoang & Mohammed Khalfan
#For collaboration requests, please email: [email protected]

def initialize(context):
    context.currently_holding = []
    schedule_function(clear, date_rules.month_end(), time_rules.market_open())
    schedule_function(rebalance, date_rules.month_end(), time_rules.market_close(minutes=5))
    schedule_function(record_vars, date_rules.every_day(), time_rules.market_close())
    set_benchmark(symbol('SPY'))
    
def clear(context, data):

    today = get_datetime()
    if not (today.month == 12 or today.month == 3 or today.month == 6 or today.month == 9):
        return
    print("1" + str(context.currently_holding))
    for s in context.currently_holding:
        order_target_percent(s, 0)
    context.currently_holding = []   

def rebalance(context, data):
    today = get_datetime()
    if not (today.month == 12 or today.month == 3 or today.month == 6 or today.month == 9):
        return
    
    assets = symbols('XIV','SPXL','TQQQ','TNA')
    safe = symbol('AGG')
    tlt = symbol('TMF')
    
    mom_period = 22 #22 days = 1 month 

    hist = data.history(assets, 'price', mom_period + 1, '1d')  
    mom = (hist.iloc[-1]/hist.iloc[0]) - 1.0  
    mom = mom.dropna()  
    top_assets = mom.sort_values().index[-4:]
    top_assets_values = mom.sort_values()[-4:]
    
    safe_percent = 0
    tlt_percent = 0
    
    for x in range(len(top_assets)):
        if top_assets_values[x] > 0:
            if data.can_trade(top_assets[x]):  
                order_target_percent(top_assets[x], 0.25)
                context.currently_holding.append(top_assets[x])
        else:
            hist = data.history(safe, 'price', mom_period + 1, '1d')  
            mom = (hist[-1]/hist[0]) - 1.0
            if mom > 0:
                safe_percent += 0.25
                if safe not in context.currently_holding: context.currently_holding.append(safe)
            else:
                tlt_percent += 0.25
                if tlt not in context.currently_holding: context.currently_holding.append(tlt)
                    
    top_up = 4 - len(top_assets)
    if top_up > 0:
        for x in range(top_up):
            hist = data.history(safe, 'price', mom_period + 1, '1d')  
            mom = (hist[-1]/hist[0]) - 1.0
            if mom > 0:
                safe_percent += 0.25
                if safe not in context.currently_holding: context.currently_holding.append(safe)
            else:
                tlt_percent += 0.25
                if tlt not in context.currently_holding: context.currently_holding.append(tlt)
    print(safe_percent, tlt_percent)
    if safe_percent > 0.0 and data.can_trade(safe):  
        order_target_percent(safe, safe_percent)                        
                        
    if tlt_percent > 0.0 and data.can_trade(tlt):  
        order_target_percent(tlt, tlt_percent)
        
    print("2" + str(context.currently_holding)  )
def record_vars(context, data):
    record(leverage = context.account.leverage)
There was a runtime error.

This algo is also quite robust! Here's a version of it trading a basket of commodity ETFs which if bought-and-held would have all gotten hammered since 2008.

Clone Algorithm
764
Loading...
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
#DUAL MOMENTUM QUARTERLY ROTATION
#Developers: Kory Hoang & Mohammed Khalfan
#For collaboration requests, please email: [email protected]

def initialize(context):
    context.currently_holding = []
    schedule_function(clear, date_rules.month_end(), time_rules.market_open())
    schedule_function(rebalance, date_rules.month_end(), time_rules.market_close(minutes=5))
    schedule_function(record_vars, date_rules.every_day(), time_rules.market_close())
    set_benchmark(symbol('SPY'))
    
def clear(context, data):

    today = get_datetime()
    if not (today.month == 12 or today.month == 3 or today.month == 6 or today.month == 9):
        return
    print("1" + str(context.currently_holding))
    for s in context.currently_holding:
        order_target_percent(s, 0)
    context.currently_holding = []   

def rebalance(context, data):
    today = get_datetime()
    if not (today.month == 12 or today.month == 3 or today.month == 6 or today.month == 9):
        return
    
    assets = symbols('GSG','GLD','SLV','USO','UNG')
    safe = symbol('AGG')
    tlt = symbol('TLT')
    
    mom_period = 22 #22 days = 1 month 

    hist = data.history(assets, 'price', mom_period + 1, '1d')  
    mom = (hist.iloc[-1]/hist.iloc[0]) - 1.0  
    mom = mom.dropna()  
    top_assets = mom.sort_values().index[-4:]
    top_assets_values = mom.sort_values()[-4:]
    
    safe_percent = 0
    tlt_percent = 0
    
    for x in range(len(top_assets)):
        if top_assets_values[x] > 0:
            if data.can_trade(top_assets[x]):  
                order_target_percent(top_assets[x], 0.25)
                context.currently_holding.append(top_assets[x])
        else:
            hist = data.history(safe, 'price', mom_period + 1, '1d')  
            mom = (hist[-1]/hist[0]) - 1.0
            if mom > 0:
                safe_percent += 0.25
                if safe not in context.currently_holding: context.currently_holding.append(safe)
            else:
                tlt_percent += 0.25
                if tlt not in context.currently_holding: context.currently_holding.append(tlt)
                    
    top_up = 4 - len(top_assets)
    if top_up > 0:
        for x in range(top_up):
            hist = data.history(safe, 'price', mom_period + 1, '1d')  
            mom = (hist[-1]/hist[0]) - 1.0
            if mom > 0:
                safe_percent += 0.25
                if safe not in context.currently_holding: context.currently_holding.append(safe)
            else:
                tlt_percent += 0.25
                if tlt not in context.currently_holding: context.currently_holding.append(tlt)
    print(safe_percent, tlt_percent)
    if safe_percent > 0.0 and data.can_trade(safe):  
        order_target_percent(safe, safe_percent)                        
                        
    if tlt_percent > 0.0 and data.can_trade(tlt):  
        order_target_percent(tlt, tlt_percent)
        
    print("2" + str(context.currently_holding)  )
def record_vars(context, data):
    record(leverage = context.account.leverage)
There was a runtime error.

Hi there, would you mind to explain how your algorithm works? I can't quite get the logic after reading the code since I am new to quantopian. Thanks.

I believe it's based on Dual Momentum / Global Equity Momentum: http://www.optimalmomentum.com/momentum.html

Hi Viridian,

Yes I understand that, but I can't get the purpose of a few bits in the code:

    hist = data.history(assets, 'price', mom_period + 1, '1d')  
    mom = (hist.iloc[-1]/hist.iloc[0]) - 1.0  
    mom = mom.dropna()  
    top_assets = mom.sort_values().index[-4:]  
    top_assets_values = mom.sort_values()[-4:]

It seems sorting the performance of the 4 assets 'SPY','EFA','EEM','GLD', then I am not sure what this means "mom = (hist[-1]/hist[0]) - 1.0", and why it is trying to do the following.

       else:  
            hist = data.history(safe, 'price', mom_period + 1, '1d')  
            mom = (hist[-1]/hist[0]) - 1.0  
            if mom > 0:  
                safe_percent += 0.25  
                if safe not in context.currently_holding: context.currently_holding.append(safe)  
            else:  
                tlt_percent += 0.25  
                if tlt not in context.currently_holding: context.currently_holding.append(tlt)  

Hi there,

So is this algorithm trying to do the following:

1) If the current month is not Mar / Jun / Sep / Dec, do nothing

2) Otherwise,

a) Retrieve the price of last 23 days. Divide the price of yesterday by the price of today then minus 1 to get the momentum.
b) Sort the momentum of SPY/EFA/EEM/GLD and get the best performing asset.
c) Do the same to decide the safe asset for AGG vs TLT
d) Calculate the weighting
e) Buy the best performing asset, the rest allocate to safe asset.

Repeat the same steps everyday in Mar/Jun/Sep/Dec.

I still can't understanding what this line really means:

    top_assets = assets_return.sort_values().index[-n:]  

Is it trying to select the best performing asset or I am wrong?

Appreciated for your explanation Vladimir. Will try to study the API documentation more to make sure I get that right.

XIV created a problem or two. Easy with hindsight, innit

Clone Algorithm
2
Loading...
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
#DUAL MOMENTUM QUARTERLY ROTATION
#Developers: Kory Hoang & Mohammed Khalfan
#For collaboration requests, please email: [email protected]

def initialize(context):
    context.currently_holding = []
    schedule_function(clear, date_rules.month_end(), time_rules.market_open())
    schedule_function(rebalance, date_rules.month_end(), time_rules.market_close(minutes=5))
    schedule_function(record_vars, date_rules.every_day(), time_rules.market_close())
    set_benchmark(symbol('SPY'))
    
def clear(context, data):

    today = get_datetime()
    if not (today.month == 12 or today.month == 3 or today.month == 6 or today.month == 9):
        return
    print("1" + str(context.currently_holding))
    for s in context.currently_holding:
        order_target_percent(s, 0)
    context.currently_holding = []   

def rebalance(context, data):
    today = get_datetime()
    if not (today.month == 12 or today.month == 3 or today.month == 6 or today.month == 9):
        return
    
    assets = symbols('XIV','SPXL','TQQQ','TNA')
    safe = symbol('AGG')
    tlt = symbol('TLT')
    
    mom_period = 22 #22 days = 1 month 

    hist = data.history(assets, 'price', mom_period + 1, '1d')  
    mom = (hist.iloc[-1]/hist.iloc[0]) - 1.0  
    mom = mom.dropna()  
    top_assets = mom.sort_values().index[-4:]
    top_assets_values = mom.sort_values()[-4:]
    
    safe_percent = 0
    tlt_percent = 0
    
    for x in range(len(top_assets)):
        if top_assets_values[x] > 0:
            if data.can_trade(top_assets[x]):  
                order_target_percent(top_assets[x], 0.25)
                context.currently_holding.append(top_assets[x])
        else:
            hist = data.history(safe, 'price', mom_period + 1, '1d')  
            mom = (hist[-1]/hist[0]) - 1.0
            if mom > 0:
                safe_percent += 0.25
                if safe not in context.currently_holding: context.currently_holding.append(safe)
            else:
                tlt_percent += 0.25
                if tlt not in context.currently_holding: context.currently_holding.append(tlt)
                    
    top_up = 4 - len(top_assets)
    if top_up > 0:
        for x in range(top_up):
            hist = data.history(safe, 'price', mom_period + 1, '1d')  
            mom = (hist[-1]/hist[0]) - 1.0
            if mom > 0:
                safe_percent += 0.25
                if safe not in context.currently_holding: context.currently_holding.append(safe)
            else:
                tlt_percent += 0.25
                if tlt not in context.currently_holding: context.currently_holding.append(tlt)
    print(safe_percent, tlt_percent)
    if safe_percent > 0.0 and data.can_trade(safe):  
        order_target_percent(safe, safe_percent)                        
                        
    if tlt_percent > 0.0 and data.can_trade(tlt):  
        order_target_percent(tlt, tlt_percent)
        
    print("2" + str(context.currently_holding)  )
def record_vars(context, data):
    record(leverage = context.account.leverage)
There was a runtime error.

Yikes. Even without the XIV implosion, it has performed quite poorly since 2018.

I'm not convinced that dual momentum strategies aren't significantly the result of data mining/curve fitting.

Even the non leveraged version has had a tough time. But.... Who knows?

So, the algo was published mid 2017 with a good back tested track record and then, bingo, 2018 was down. And yet the concept seems valid. To me at least.

Clone Algorithm
23
Loading...
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
#DUAL MOMENTUM QUARTERLY ROTATION
#Developers: Kory Hoang & Mohammed Khalfan
#For collaboration requests, please email: [email protected]

def initialize(context):
    context.currently_holding = []
    schedule_function(clear, date_rules.month_end(), time_rules.market_open())
    schedule_function(rebalance, date_rules.month_end(), time_rules.market_close(minutes=5))
    schedule_function(record_vars, date_rules.every_day(), time_rules.market_close())
    set_benchmark(symbol('SPY'))
    
def clear(context, data):

    today = get_datetime()
    if not (today.month == 12 or today.month == 3 or today.month == 6 or today.month == 9):
        return
    print("1" + str(context.currently_holding))
    for s in context.currently_holding:
        order_target_percent(s, 0)
    context.currently_holding = []   

def rebalance(context, data):
    today = get_datetime()
    if not (today.month == 12 or today.month == 3 or today.month == 6 or today.month == 9):
        return
    
    assets = symbols('SPY','EFA','EEM','GLD')
    safe = symbol('AGG')
    tlt = symbol('TLT')
    
    mom_period = 22 #22 days = 1 month 

    hist = data.history(assets, 'price', mom_period + 1, '1d')  
    mom = (hist.iloc[-1]/hist.iloc[0]) - 1.0  
    mom = mom.dropna()  
    top_assets = mom.sort_values().index[-4:]
    top_assets_values = mom.sort_values()[-4:]
    
    safe_percent = 0
    tlt_percent = 0
    
    for x in range(len(top_assets)):
        if top_assets_values[x] > 0:
            if data.can_trade(top_assets[x]):  
                order_target_percent(top_assets[x], 0.25)
                context.currently_holding.append(top_assets[x])
        else:
            hist = data.history(safe, 'price', mom_period + 1, '1d')  
            mom = (hist[-1]/hist[0]) - 1.0
            if mom > 0:
                safe_percent += 0.25
                if safe not in context.currently_holding: context.currently_holding.append(safe)
            else:
                tlt_percent += 0.25
                if tlt not in context.currently_holding: context.currently_holding.append(tlt)
                    
    top_up = 4 - len(top_assets)
    if top_up > 0:
        for x in range(top_up):
            hist = data.history(safe, 'price', mom_period + 1, '1d')  
            mom = (hist[-1]/hist[0]) - 1.0
            if mom > 0:
                safe_percent += 0.25
                if safe not in context.currently_holding: context.currently_holding.append(safe)
            else:
                tlt_percent += 0.25
                if tlt not in context.currently_holding: context.currently_holding.append(tlt)
    print(safe_percent, tlt_percent)
    if safe_percent > 0.0 and data.can_trade(safe):  
        order_target_percent(safe, safe_percent)                        
                        
    if tlt_percent > 0.0 and data.can_trade(tlt):  
        order_target_percent(tlt, tlt_percent)
        
    print("2" + str(context.currently_holding)  )
def record_vars(context, data):
    record(leverage = context.account.leverage)
There was a runtime error.

The square brackets are PnL, added for each sell in this hacked version of track_orders.
Underscore means 0 shares, just easier to spot.
Current shares in parens.
First column is minute of the trading day.
Last column is last four of order id.
At times, there are partial fills, they look like this:

2008-03-31 6:31 clear:31 INFO held: ['TLT', 'SPY', 'GLD']   safe 0   tlt 0.5  
2008-03-31 6:31 _trac:147 INFO    1   Sell -92 TLT (92) at 96.09                            116  92a8  
2008-03-31 6:31 _trac:147 INFO    1   Sell -29 SPY (29) at 131.23                           116  9b92  
2008-03-31 6:31 _trac:147 INFO    1   Sell -52 GLD (52) at 92.58                            116  c267  
2008-03-31 6:32 _trac:147 INFO    2      Sold -30/-92 TLT (62) at 96.10   [+90]             11617  92a8  < Sold 30  
2008-03-31 6:32 _trac:147 INFO    2      Sold -29 SPY _ at 131.34   [-448]                  11617  9b92  
2008-03-31 6:32 _trac:147 INFO    2      Sold -52 GLD _ at 92.59   [+530]                   11617  c267  
2008-03-31 6:33 _trac:147 INFO    3      Sold -92 TLT _ at 96.09   [+276]                   17572  92a8  < Complete  

In one time period at least, 22 for mom_period (which then winds up as 23 day history) was far better than 21. Wonder why.

Clone Algorithm
25
Loading...
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
# DUAL MOMENTUM QUARTERLY ROTATION
# Developers: Kory Hoang & Mohammed Khalfan
# For collaboration requests, please email: [email protected]

def initialize(context):
    c = context
    #set_benchmark(symbol('SPY'))
    c.assets       = symbols('SPY','EFA','EEM','GLD')
    c.safe         = symbol('AGG')
    c.tlt          = symbol('TLT')
    c.safe_percent = 0       # Available for report after a chance for the order to go thru
    c.tlt_percent  = 0       #    which doesn't happen until at least the next minute.
    c.mom_period   = 22      # There are 21.0 trading days per month average.
    c.pf = c.portfolio.portfolio_value  # snapshot in time before trade()

    trademin = 5  # to run reprt a minute after trade, after orders tried to fill
    schedule_function(daily, date_rules.every_day(), time_rules.market_open())
    schedule_function(clear, date_rules.month_end(), time_rules.market_open())
    schedule_function(trade, date_rules.month_end(), time_rules.market_close(minutes=trademin))
    schedule_function(reprt, date_rules.month_end(), time_rules.market_close(minutes=trademin-1))

    for i in range(1, 391):
        schedule_function(track_orders, date_rules.every_day(), time_rules.market_open(minutes=i))

def clear(context, data):
    if not get_datetime().month in [3, 6, 9, 12]:
        return

    log.info('held: {}   safe {}   tlt {}'.format(
        [str(s.symbol) for s in context.portfolio.positions],
        context.safe_percent, context.tlt_percent
    ))

    for s in context.portfolio.positions:
        order_target(s, 0)

def trade(context, data):
    if not get_datetime().month in [3, 6, 9, 12]:
        return

    c = context

    hst_safe = data.history(c.safe,   'price', c.mom_period + 1, '1d').dropna() # tho never na as 1d and big stocks
    hist     = data.history(c.assets, 'price', c.mom_period + 1, '1d').dropna()
    mom      = (hist.iloc[-1] / hist.iloc[0]) - 1.0
    top_assets        = mom.sort_values().index[-4:]
    top_assets_values = mom.sort_values()[-4:]
    '''
    top_assets_values: Series
        Equity(8554 [SPY]): -0.0212642970179
        Equity(26807 [GLD]): -0.0180655199239
        Equity(22972 [EFA]): -0.0170221901885
        Equity(24705 [EEM]): -0.0141381853946
    '''

    c.safe_percent = 0
    c.tlt_percent  = 0

    for x in range(len(top_assets)):  # len(top_assets) like 4
        if top_assets_values[x] > 0:
            if not data.can_trade(top_assets[x]):
                continue
            order_target_percent(top_assets[x], 0.25)
        else:
            mom = (hst_safe[-1] / hst_safe[0]) - 1.0   # like 0.00841933731668
            if mom > 0: c.safe_percent += 0.25
            else:       c.tlt_percent  += 0.25

    top_up = 4 - len(top_assets)  # number of those above 0
    if top_up > 0:
        for x in range(top_up):
            mom = (hst_safe[-1] / hst_safe[0]) - 1.0
            if mom > 0: c.safe_percent += 0.25
            else:       c.tlt_percent  += 0.25

    if c.safe_percent > 0.0:
        order_target_percent(c.safe, c.safe_percent)

    if c.tlt_percent  > 0.0:
        order_target_percent(c.tlt,  c.tlt_percent)

def reprt(context, data):
    if not get_datetime().month in [3, 6, 9, 12]:
        return

    c = context

    # Corresponds to the safe & tlt settings of the previous time period.
    pf_gain = int(c.portfolio.portfolio_value - c.pf)
    # This part is confusing, there's a lag, misalignment in the chart. Leaving any resolution to you.

    # Reset for next compare since last trade.
    c.pf = c.portfolio.portfolio_value

    log.info('held: {}   safe {}   tlt {}   pf +/- since last trade {}'.format(
        [str(s.symbol) for s in c.portfolio.positions],
        c.safe_percent, c.tlt_percent, pf_gain
    ))
    record(pf_gain  = pf_gain)  # Effect of previous trade(). Remember chart does smoothing.
    record(safe_pct = 100 * c.safe_percent)
    record(tlt_pct  = 100 * c.tlt_percent )
    record(pos      = len(c.portfolio.positions))

def daily(context, data):
    record(lvrg = context.account.leverage)

# A rough, hacked version for pnl, only suited for long-only at the moment ...
def track_orders(context, data):
    '''  Show orders when made and filled.
           Info: https://www.quantopian.com/posts/track-orders
    '''
    c = context
    if 'trac' not in c:
        c.cb = {}    # rough hack to store cost_basis to be able to calculate pnl
        c.t_opts = {        # __________    O P T I O N S    __________
            'symbols'     : [],   # List of symbols to filter for, like ['TSLA', 'SPY']
            'log_neg_cash': 1,    # Show cash only when negative.
            'log_cash'    : 1,    # Show cash values in logging window or not.
            'log_ids'     : 1,    # Include order id's in logging window or not.
            'log_unfilled': 1,    # When orders are unfilled. (stop & limit excluded).
            'log_cancels' : 0,    # When orders are canceled.
        }    # Move these to initialize() for better efficiency.
        c.trac = {}
        c.t_dates  = {  # To not overwhelm the log window, start/stop dates can be entered.
            'active': 0,
            'start' : [],   # Start dates, option like ['2007-05-07', '2010-04-26']
            'stop'  : []    # Stop  dates, option like ['2008-02-13', '2010-11-15']
        }
        log.info('track_orders active. Headers ...')
        log.info('             Shares     Shares')
        log.info('Min   Action Order  Sym  Now   at Price   PnL   Stop or Limit   Cash  Id')
    from pytz import timezone as _tz  # Python only does once, makes this portable.
                                      #   Move to top of algo for better efficiency.
    # If 'start' or 'stop' lists have something in them, triggers ...
    if c.t_dates['start'] or c.t_dates['stop']:
        _date = str(get_datetime().date())
        if   _date in c.t_dates['start']:    # See if there's a match to start
            c.t_dates['active'] = 1
        elif _date in c.t_dates['stop']:     #   ... or to stop
            c.t_dates['active'] = 0
    else: c.t_dates['active'] = 1           # Set to active b/c no conditions.
    if c.t_dates['active'] == 0: return     # Skip if not active.
    def _minute():   # To preface each line with the minute of the day.
        bar_dt = get_datetime().astimezone(_tz('US/Eastern'))
        return (bar_dt.hour * 60) + bar_dt.minute - 570 # (-570 = 9:31a)
    def _trac(to_log):      # So all logging comes from the same line number,
        log.info(' {}   {}'.format(str(_minute()).rjust(3), to_log))  # for vertical alignment in the logging window.

    for oid in c.trac.copy():               # Existing known orders
      o = get_order(oid)
      if c.t_opts['symbols'] and (o.sid.symbol not in c.t_opts['symbols']): continue
      if o.dt == o.created: continue        # No chance of fill yet.
      cash = ''
      prc  = data.current(o.sid, 'price') if data.can_trade(o.sid) else c.portfolio.positions[o.sid].last_sale_price
      if (c.t_opts['log_neg_cash'] and c.portfolio.cash < 0) or c.t_opts['log_cash']:
        cash = str(int(c.portfolio.cash))
      if o.status == 2:                     # Canceled
        do = 'Buy' if o.amount > 0 else 'Sell' ; style = ''
        if o.stop:
          style = ' stop {}'.format(o.stop)
          if o.limit: style = ' stop {} limit {}'.format(o.stop, o.limit)
        elif o.limit: style = ' limit {}'.format(o.limit)
        if c.t_opts['log_cancels']:
          _trac('  Canceled {} {} {}{} at {}   {}  {}'.format(do, o.amount,
             o.sid.symbol, style, prc, cash, o.id[-4:] if c.t_opts['log_ids'] else ''))
        del c.trac[o.id]
      elif o.filled:                        # Filled at least some.
        filled = '{}'.format(o.amount)
        filled_amt = 0
        #if o.status == 1:                   # Nope, is either partial or complete
        if o.filled == o.amount:             # Complete
          if 0 < c.trac[o.id] < o.amount:
            filled   = 'all {}/{}'.format(o.filled - c.trac[o.id], o.amount)
          filled_amt = o.filled
        else:                                    # c.trac[o.id] value is previously filled total
          filled_amt = o.filled - c.trac[o.id]   # filled this time, can be 0
          c.trac[o.id] = o.filled                # save fill value for increments math
          filled = '{}/{}'.format(filled_amt, o.amount)
        # pnl hack
        if filled_amt:
          if   filled_amt > 0:
              c.cb[o.sid] = c.portfolio.positions[o.sid].cost_basis  # last known cb but long only, hack
          elif filled_amt < 0:  # sold some. only suitable now for long only like this backtest
            if o.sid in c.cb:
                cb  = c.cb[o.sid]
            else:
                cb  = ''
          now = ' ({})'.format(c.portfolio.positions[o.sid].amount) if c.portfolio.positions[o.sid].amount else ' _'
          pnl   = ''  # for the trade only
          style = ''
          # tmp diag for pnl hack
          #log.info('{}  (amt {} - fild {}) * fild {} = {}'.format(o.sid.symbol,
          #      c.portfolio.positions[o.sid].amount, o.filled, o.filled,
          #      (c.portfolio.positions[o.sid].amount - o.filled) * o.filled
          #))
          if (c.portfolio.positions[o.sid].amount - o.filled) * o.filled < 0:    # Profit-taking scenario including short-buyback
            #cb = c.portfolio.positions[o.sid].cost_basis
            if cb:
              pnl  = -filled_amt * (prc - cb)
              sign = '+' if pnl > 0 else '-'
              pnl  = '  [{}{}]'.format(sign, '%.0f' % abs(pnl))
          if o.stop:
            style = ' stop {}'.format(o.stop)
            if o.limit: style = ' stop () limit {}'.format(o.stop, o.limit)
          elif o.limit: style = ' limit {}'.format(o.limit)
          if o.filled == o.amount: del c.trac[o.id]
          _trac('   {} {} {}{} at {} {} {}'.format(
            'Bot' if filled_amt > 0 else 'Sold', filled, o.sid.symbol, now,
            '%.2f' % prc, pnl.rjust(6), style).ljust(52) + '  {}  {}'.format(cash, o.id[-4:] if c.t_opts['log_ids'] else ''))
      elif c.t_opts['log_unfilled'] and not (o.stop or o.limit):
        _trac('      {} {}{} unfilled  {}'.format(o.sid.symbol, o.amount,
         ' limit' if o.limit else '', o.id[-4:] if c.t_opts['log_ids'] else ''))

    oo = get_open_orders().values()
    if not oo: return                       # Handle new orders
    cash = ''
    if (c.t_opts['log_neg_cash'] and c.portfolio.cash < 0) or c.t_opts['log_cash']:
      cash = str(int(c.portfolio.cash))
    for oo_list in oo:
      for o in oo_list:
        if c.t_opts['symbols'] and (o.sid.symbol not in c.t_opts['symbols']): continue
        if o.id in c.trac: continue         # Only new orders beyond this point
        prc = data.current(o.sid, 'price') if data.can_trade(o.sid) else c.portfolio.positions[o.sid].last_sale_price
        c.trac[o.id] = 0 ; style = ''
        now  = ' ({})'.format(c.portfolio.positions[o.sid].amount) if c.portfolio.positions[o.sid].amount else ' _'
        if o.stop:
          style = ' stop {}'.format(o.stop)
          if o.limit: style = ' stop {} limit {}'.format(o.stop, o.limit)
        elif o.limit: style = ' limit {}'.format(o.limit)
        _trac('{} {} {}{} at {}{}'.format('Buy' if o.amount > 0 else 'Sell',
          o.amount, o.sid.symbol, now, '%.2f' % prc, style).ljust(52) + '  {}  {}'.format(cash, o.id[-4:] if c.t_opts['log_ids'] else ''))
There was a runtime error.

One factor which has certainly adversely affected the algo is that interest rates have no further to fall. Rising interest rates may mean the safe asset ought to be money markets or cash. But that will certainly badly affect the historic CAGR. Endless fiddling to "improve" the algo will undoubtedly destroy it.

Unfortunately that way lies curve fitting and madness.

Interesting to look back at "Wild About Harry".
The back test I ran ended in August 2016. Here is an update using TLT as the bond element. As perhaps I expected, you would have cursed its under-performance in the last couple of years. But One might assume it will prove its worth in a Black Swan event.

Oops

Clone Algorithm
19
Loading...
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 THE LIBRARIES USED IN THE ALGORITHM ####################################
import datetime
import pytz
import pandas as pd



########### INITIALZE() IS RUN ONCE (OR IN LIVE TRADING ONCE EACH DAY BEFORE TRADING) #####
def initialize(context):
    
    # Define the instruments in the portfolio:
    context.SPY      = symbol('SPY')
    context.TLT      = symbol('TLT')
    context.IEF      = symbol('IEF')
    context.IWV      = symbol('IWV')
    context.QQQ      = symbol('QQQ')
    context.EFA      = symbol('EFA')
    context.SHY      = symbol('SHY')
    context.GLD      = symbol('GLD')

    
    #start_date = context.SPY.security_start_date
    #end_date   = context.SPY.security_end_date
    
    # Initialize context variables the define rebalance logic:
    context.rebalance_date = None
    context.next_rebalance_Date = None
    context.rebalance_days = 360

    
    
########### HANDLE_DATA() IS RUN ONCE PER MINUTE #######################
def handle_data(context, data):
    
    # Get the current exchange time, in local timezone: 
    exchange_time = pd.Timestamp(get_datetime()).tz_convert('US/Eastern')
    
    # If it is rebalance day, rebalance:
    if  context.rebalance_date == None or exchange_time >= context.next_rebalance_date:         
           rebalance(context, data, exchange_time) 
           log.info('Rebalanced portfolio to target weights at %s' % exchange_time)
           # Update the current and next rebalance dates
           context.rebalance_date = exchange_time 
           context.next_rebalance_date = context.rebalance_date + \
           datetime.timedelta(days=context.rebalance_days)      
    else:
        return
    
########### CORE REBALANCE LOGIC #########################################
## THIS FUNCTION IS RUN ONLY AT REBALANCE (DAY/TIME) #####################
def rebalance(context,data,exchange_time):

    order_target_percent(context.GLD,0.25)
    order_target_percent(context.IWV,0.25)
    order_target_percent(context.SHY,0.25)
    order_target_percent(context.TLT,0.25)
    #order_target_percent(context.IOO,0.25)
    #order_target_percent(context.GLD,0.25)
    #order_target_percent(context.IWV,0.25)
    #order_target_percent(context.SHY,0.25)
    #order_target_percent(context.IEF,0.25)
    #order_target_percent(context.IOO,0.25)
There was a runtime error.

Would be interesting to determine the optimum rebalance schedule i.e., weekly, monthly, or biannually vs.quarterly.