Back to Community
12% Alpha - 5 Stocks - Rebalance Monthly - The Dangers of Over Fitting

About two years ago I dove deep into Quantopian and quantitative finance. I had done a fair bit of research on long term historical returns data and published this on my blog: engineeredportfolio.com. In that research, I found that the sectors of consumer staples and health care performed surprisingly well. And so did midcap value.

I had a pretty simple idea to look at the returns of companies that were both in those sectors and in that style/size. I then added some letter grade filtering based on Morningstar stock grades to make sure the companies were not terrible. Lastly, I wanted this to be something that I could trade with my own (small) account so I wanted to have a) not too many trades and b) not too many companies to spread my small amount of capital across.

What I found was a surprisingly well performing "index" of 5 stocks with the following requirements:

  • Consumer staples, health care, packaging, or restaurants
  • Mid or small cap value
  • Financial, growth, and profitable grades of C or above
  • Sort remaining ~15 companies by market cap and rebalance to middle 5
  • More explanation on the theory here: Engineering an Index Fund

Do this monthly and watch out! There was over a 10% annual outperformance with a total return of over 2,000%. So I decided to live trade it with real money (back in the day when Quantopian let you connect to brokerages). For a while, it was working beautifully! But then I learned a couple hard lessons:

  • 5 stocks are too few, especially when you don't have great conviction about them. When 1 has a bad earnings report the whole account sees massive volatility
  • On a related note, tracking error is hard to stomach. Having so few stocks that are so specific to a certain style and sector causes you to be grossly uncorrelated to benchmarks. This is hard to keep your conviction when most accounts are going up and you're hopping up and down all over the place
  • Past performance doesn't predict future returns. It's an obvious one yet I had convinced myself that I found the golden egg! I was wrong... there may be something to some of the logic I applied but it won't work cleanly and consistently enough to keep investors engaged and committed, myself included.

Quantopian eventually shut down the live trading which thankfully forced me to look for simpler rules based trading approaches which ended up leading me to "accelerating dual momentum" which continues to go well (and it's simple!).

Clone Algorithm
23
Loading...
Backtest from to with initial capital
Total Returns
--
Alpha
--
Beta
--
Sharpe
--
Sortino
--
Max Drawdown
--
Benchmark Returns
--
Volatility
--
Returns 1 Month 3 Month 6 Month 12 Month
Alpha 1 Month 3 Month 6 Month 12 Month
Beta 1 Month 3 Month 6 Month 12 Month
Sharpe 1 Month 3 Month 6 Month 12 Month
Sortino 1 Month 3 Month 6 Month 12 Month
Volatility 1 Month 3 Month 6 Month 12 Month
Max Drawdown 1 Month 3 Month 6 Month 12 Month
from quantopian.pipeline import Pipeline, CustomFactor
from quantopian.algorithm import attach_pipeline, pipeline_output
from quantopian.pipeline.data.builtin import USEquityPricing
from quantopian.pipeline.filters.morningstar import Q500US, Q1500US
from quantopian.pipeline.classifiers.fundamentals import Sector 
#from quantopian.pipeline.classifiers.morningstar import Sector  
#from quantopian.pipeline.data import morningstar
from quantopian.pipeline.data import Fundamentals 
import pandas as pd
import math
import numpy as np

#Parameters
ROBINHOOD_GOLD = 0            #Margin available through Robinhood Gold
 
def initialize(context):
    """
    Called once at the start of the algorithm.
    """   
    set_commission(commission.PerTrade(cost=0.00))
        
    #Schedule Monthly Functions
    schedule_function(set_indexes, date_rules.month_end(), time_rules.market_open())
    schedule_function(my_rebalance, date_rules.month_start(), time_rules.market_open(hours=1))
    
    #Schedule Daily Functions
    schedule_function(mid_month_start, date_rules.every_day(), time_rules.market_open(minutes=15))
    schedule_function(buy_longs, date_rules.month_start(), time_rules.market_open(hours=2))
    schedule_function(my_record_vars, date_rules.every_day(), time_rules.market_close())
        
    context.long_turnover = 0
    context.started = 0
    context.go = 1
    context.fill = sid(23911)
        
    # Create our dynamic stock selector.
    attach_pipeline(make_pipeline(), 'my_pipeline')
    
def before_trading_start(context, data):
    if context.go == 0:
        return
    context.go = 0
    context.started = 1
    
    #Get Pipeline Output
    stocks = pipeline_output('my_pipeline')
    stocks = stocks[(stocks.Finan != 'D-') & (stocks.Finan != 'D') & (stocks.Finan != 'D+') & (stocks.Finan != 'F')]
    stocks = stocks.sort_values(by='M_Cap', ascending=False) 
    
    #get the super secret defense and offense stock engineered index
    defense = stocks[(stocks.Sector != 102)]    
    offense = stocks[(stocks.Profit != 'C') & (stocks.Profit != 'C-') & (stocks.Sector != 205) & (stocks.Sector != 206)]
    a = offense[offense.Ind == 10213] #packaging
    b = offense[offense.Ind == 10216] #restaurants   
    offense = pd.concat([a, b])
    stocks = pd.concat([defense, offense])
    stocks = stocks.sort_values(by='M_Cap', ascending=False)        
    
    #Print out symbols
    log.info(stocks[['M_Cap','Ind']])
    
    #Get middle stocks
    n = len(stocks)
    m = 5
    i=0
    if n > m:
        i = int(math.ceil((n-m)/2))
    stocks = stocks.iloc[i:(i+m)].copy()
    
    scale = (context.portfolio.portfolio_value+ROBINHOOD_GOLD)/context.portfolio.portfolio_value
    
    #Scale stock weights
    context.good = stocks
    context.good['Weight'] = 1.0/5.0
    context.good.loc[context.fill, 'Weight'] = 0.0
    weight = context.good['Weight'].values.tolist()        
    weight = [x * scale for x in weight]   
    context.good['Weight'] = weight
    
def buy_longs(context, data):
    """
    Determine stocks undervalued and put cash to work to buy what's needed
    """    
    if context.started == 0:
        set_indexes(context,data) 
        
    stocks = context.good.index.tolist()
    weight = context.good['Weight'].values.tolist()      
    n = len(weight)
    if n < 1:
        return
    
    #Determine necessary contribution
    for x in range(0, n):
        desired_balance = context.good.loc[stocks[x], 'Weight']*context.portfolio.portfolio_value
        curr_price = data.current(stocks[x],'price')
        current_balance = context.portfolio.positions[stocks[x]].amount*curr_price
        context.good.loc[stocks[x], 'Need'] = desired_balance-current_balance
        context.good.loc[stocks[x], 'Price'] = curr_price*1.005
    
    #Determine how much to get of each (truncate by share price)
    context.good['Get'] = context.good['Need']
    context.good.loc[context.good.Get < 0,'Get'] = 0 #set all gets less than 0 to 0
    get_sum = context.good['Get'].sum()
    if get_sum == 0:
        get_sum = 1
    cash = context.portfolio.cash + ROBINHOOD_GOLD
    context.good['Get'] = context.good['Get']*cash/get_sum #scale gets by available cash
    context.good.loc[context.good.Get < 0,'Get'] = 0 #set all gets less than 0 to 0
    context.good['Shares'] = np.trunc(context.good['Get']/context.good['Price']) #determine number of shares to buy
    context.good['Get'] = context.good['Shares'] * context.good['Price'] #recalculate how much will be bought from truncated shares
    
    #Figure out remaining cash and buy more of the stock that needs it most
    cash = cash - context.good['Get'].sum()
    context.good.loc[context.fill,'Get'] += cash #use up all cash
    context.good['Shares'] = np.trunc(context.good['Get']/context.good['Price']) #recalculate number of shares after adding left over cash back in
    context.good['Get'] = context.good['Shares'] * context.good['Price'] #recalculate how much will be bought from truncated shares
    
    #place orders for each asset
    for x in range(0, n):   
        if data.can_trade(stocks[x]):         
            order(stocks[x], context.good.loc[stocks[x], 'Shares'], style=LimitOrder(context.good.loc[stocks[x], 'Price']))
    log.info(context.good[['Weight','Need','Get']].sort_values(by='Need', ascending=False))
         
def make_pipeline():
    
    finan = Fundamentals.financial_health_grade.latest
    filter_finan = (finan != 'D') & (finan != 'F') & (finan != None)

    growth = Fundamentals.growth_grade.latest
    filter_growth = (growth != 'F') & (growth != None)

    profit = Fundamentals.profitability_grade.latest
    filter_profit = (profit != None) & (profit != 'F') & (profit != 'D') & (profit != 'D-') & (profit != 'D+')

    sector = Sector()
    sector_filter = (sector != 101) & (sector != 103) & (sector != 104) & (sector != 207) & (sector != 308) & (sector != 309) & (sector != 310) & (sector != 311)   
    
    box = Fundamentals.style_box.latest
    box_filter = (box != 1) & (box != 2) & (box != 3) & (box != 5) & (box != 6) & (box != 8) & (box != 9) & (box != -1) 
    
    pipe =  Pipeline(
        columns={'M_Cap': Fundamentals.market_cap.latest,
                 'Sector': Sector(),
                 'Ind': Fundamentals.morningstar_industry_group_code.latest,
                 'Finan': finan,
                 'Profit': profit
                },
    screen = Q1500US() & filter_growth & filter_profit & sector_filter & box_filter
    )
    return pipe          

def set_indexes(context,data):
    context.go = 1
    
def mid_month_start(context,data):
    if context.started == 0:
        my_rebalance(context,data)    
    
def my_rebalance(context,data):
    """
    Execute orders according to our schedule_function() timing. 
    """
    if context.started == 0:
        set_indexes(context,data) 
        
    context.long_turnover = 0
    good_stocks = context.good.index.tolist()
    print_out = ''
    
    #Sell stocks that are not in our lists
    for security in context.portfolio.positions:
        cost = context.portfolio.positions[security].cost_basis
        price = context.portfolio.positions[security].last_sale_price
        amount = context.portfolio.positions[security].amount
        gain = (price-cost)*amount
        if security not in good_stocks and data.can_trade(security):
            print_out += '\nSell: ' + security.symbol + ' | Gains: $' + '{:06.2f}'.format(gain) + ' | Gain: ' + '{:04.2f}'.format((price/cost-1)*100) + '%'
            order_target_percent(security,0)
            context.long_turnover += 1
                    
    #Determine weights and trim good stocks
    """
    n = len(good_stocks)
    curr_weights = np.zeros(n)
    weight = context.good['Weight'].values.tolist()        
    for x in range(0, n):   
        security = good_stocks[x]
        curr_weights[x] = context.portfolio.positions[security].amount * context.portfolio.positions[security].last_sale_price / context.portfolio.portfolio_value
        if curr_weights[x] >  weight[x]:
            print_out += '\nTrim: ' + security.symbol
            order_target_percent(good_stocks[x],weight[x])
    log.info(print_out)
    """
    order_target_percent(context.fill, 0.0)
            
def my_record_vars(context, data):
    """
    Plot variables at the end of each day.
    """
    long_count = 0

    for position in context.portfolio.positions.itervalues():
        if position.amount > 0:
            long_count += 1    
            
    max_percentage = 0           
    gains = 0
    for security in context.portfolio.positions:
        amount = context.portfolio.positions[security].amount
        price = context.portfolio.positions[security].last_sale_price
        cost = context.portfolio.positions[security].cost_basis
        gains += (price-cost)*amount 
        allocation = amount*price/context.portfolio.portfolio_value
        if allocation > max_percentage:
            max_percentage = allocation   
    
    # Plot the counts
    record(churn=context.long_turnover, num_long=long_count, leverage=context.account.leverage, unrealized = gains, biggest = max_percentage)
There was a runtime error.
1 response

Great post Stephen, thanks for sharing!