Quant BuffetRelax, Not Over Thinking

Momentum Combined with Insider Trading

Log in to collect

Academic paper

Momentum and Insider Trading

AuthorsQingzhong Ma

Institute
  • California State University, Chico

Strategy in a nutshell

The strategy invests in large-cap NYSE, AMEX, and NASDAQ stocks with valid book equity and prices above $5. It goes long on past winners with positive insider purchases and shorts past losers with no insider activity. Positions are equally weighted, held for 12 months, and rebalanced monthly.

Economic rationale

Momentum is reinforced when insider trading aligns with past returns. Investors underreact to insider information, and insiders are cautious with negative information due to legal risks. Positive insider trades amplify winners, while silence on losers confirms negative signals, driving predictable short-term stock performance.

Backtest performance

Annualised return15.45%
Beta-0.534
Win rate28%

Full Python code

from AlgorithmImports import *
import pandas as pd
from io import StringIO
from numpy import floor
#endregion
class MomentumCombinedInsiderTrading(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2010, 1, 1)
self.SetCash(100000)
# NOTE: We use only s&p 100 stocks so it's possible to fetch short interest data from quandl.
self.symbols = [
    'AAPL','MSFT','AMZN','FB','BRKB','GOOGL','GOOG','JPM','JNJ','V','PG','XOM','UNH','BAC','MA','T','DIS','INTC','HD','VZ','MRK',
    'PFE','CVX','KO','CMCSA','CSCO','PEP','WFC','C','BA','ADBE','WMT','CRM','MCD','MDT','BMY','ABT','NVDA','NFLX','AMGN','PM','PYPL',
    'TMO','COST','ABBV','ACN','HON','NKE','UNP','UTX','NEE','IBM','TXN','AVGO','LLY','ORCL','LIN','SBUX','AMT','LMT','GE','MMM','DHR',
    'QCOM','CVS','MO','LOW','FIS','AXP','BKNG','UPS','GILD','CHTR','CAT','MDLZ','GS','USB','CI','ANTM','BDX','TJX','ADP','TFC','CME',
    'SPGI','COP','INTU','ISRG','CB','SO','D','FISV','PNC','DUK','SYK','ZTS','MS','RTN','AGN','BLK'
    ]
    
self.period = 6 * 21

# Trenching
self.holding_period = 12
self.managed_queue = []

# Dataframe with insider trades for every stock.
self.insiders_trading = {}

# Create custom universe.
self.selection_flag = False

self.symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
self.shares_outstanding = {}
for symbol in self.symbols:
    # Import insiders trading data.
    csv_string_file = self.Download(f'data.quantpedia.com/backtesting_data/economic/insiders_trading/{symbol}.csv')
    if csv_string_file == "": continue
    parser = lambda x: pd.datetime.strptime(x, "%Y-%m-%d")
    self.insiders_trading[symbol] = pd.read_csv(StringIO(csv_string_file), sep=';', parse_dates=['Tran.Date'], date_parser=parser)
self.UniverseSettings.Resolution = Resolution.Daily
self.AddUniverseSelection(FineFundamentalUniverseSelectionModel(self.CoarseSelectionFunction, self.FineSelectionFunction))
self.Schedule.On(self.DateRules.MonthEnd(self.symbol), self.TimeRules.AfterMarketOpen(self.symbol), self.Selection)
def OnSecuritiesChanged(self, changes):
for security in changes.AddedSecurities:
    security.SetFeeModel(CustomFeeModel())
    security.SetLeverage(10)
    
def CoarseSelectionFunction(self, coarse):
if not self.selection_flag:
    return Universe.Unchanged

return [Symbol.Create(x, SecurityType.Equity, Market.USA) for x in self.symbols]
def FineSelectionFunction(self, fine):
fine = [x for x in fine if x.EarningReports.BasicAverageShares.ThreeMonths > 0 and x.Symbol.Value in self.insiders_trading]
symbols = [x.Symbol for x in fine]

history = self.History(symbols, self.period, Resolution.Daily)
if history.empty:
    self.Log(f'Empty history request for {len(symbols)} symbols')
    return Universe.Unchanged
history = history.close.unstack(0)

last_prices = {}
performance = {}

for symbol in symbols:
    if symbol in history:
        closes = history[symbol]
        if len(closes) == self.period:
            performance[symbol] = closes[-1] / closes[0] - 1
            last_prices[symbol] = closes[-1]
        
# Stock which have not been traded last 6 months.
silence = []

# Traded stocks.
nid = {}

for stock in fine:
    symbol = stock.Symbol
    
    # Get number of buys and sells during last 6 months.
    ticker = symbol.Value
    buys = [row['Shares'] for index, row in self.insiders_trading[ticker].iterrows() if row['Symbol'] == ticker and row['Tran.Date'] >= (self.Time - timedelta(days = 6 * 30)) and row['Tran.Date'] <= self.Time and row['Action'] == 'B'] 
    sells = [row['Shares'] for index, row in self.insiders_trading[ticker].iterrows() if row['Symbol'] == ticker and row['Tran.Date'] >= (self.Time - timedelta(days = 6 * 30)) and row['Tran.Date'] <= self.Time and row['Action'] == 'S']
            
    total_buy_shares = sum(buys)
    total_sell_shares = sum(sells)
    
    if len(buys) != 0 or len(sells) != 0:
        nid[symbol] = (total_buy_shares - total_sell_shares) / stock.EarningReports.BasicAverageShares.ThreeMonths
    else:
        # Stock was not traded during last 6 months.
        silence.append(symbol)
        
decile = int(len(performance) / 10)
sorted_by_performance = [x[0] for x in sorted(performance.items(), key=lambda item: item[1])]
winners = sorted_by_performance[-decile:]
losers = sorted_by_performance[:decile]

# long = [x[0] for x in performance.items() if x[1] > 0 and x[0] in nid and nid[x[0]] > 0]
# short = [x[0] for x in performance.items() if x[1] < 0 and x[0] in silence]

# Each month investor goes long past winners with a positive NID and goes short past losers from “silence” portfolio.
long = [x for x in winners if x in nid and nid[x] > 0]
short = [x for x in losers if x in silence]

if len(long) != 0:
    long_w = self.Portfolio.TotalPortfolioValue / self.holding_period / len(long)
    # symbol/quantity collection
    long_symbol_q = [(x, floor(long_w / last_prices[x])) for x in long]
else:
    long_symbol_q = []

if len(short) != 0:
    short_w = self.Portfolio.TotalPortfolioValue / self.holding_period / len(short)
    # symbol/quantity collection
    short_symbol_q = [(x, -floor(short_w / last_prices[x])) for x in short]
else:
    short_symbol_q = []

self.managed_queue.append(RebalanceQueueItem(long_symbol_q + short_symbol_q))

return long + short
def OnData(self, data):
if not self.selection_flag:
    return
self.selection_flag = False

# Trade execution
remove_item = None

# Rebalance portfolio
for item in self.managed_queue:
    if item.holding_period == self.holding_period + 1: # Each month investor goes long past winners with a positive NID and goes short past losers from “silence” portfolio.
        # Liquidate
        for symbol, quantity in item.symbol_q:
            self.MarketOrder(symbol, -quantity)
        
        remove_item = item
        
    elif item.holding_period == 1: # Each month investor goes long past winners with a positive NID and goes short past losers from “silence” portfolio.
        open_symbol_q = []
        
        for symbol, quantity in item.symbol_q:
            if symbol in data and data[symbol]:
                self.MarketOrder(symbol, quantity)
                open_symbol_q.append((symbol, quantity))
                    
        # Only opened orders will be closed        
        item.symbol_q = open_symbol_q
        
    item.holding_period += 1
    
# We need to remove closed part of portfolio after loop. Otherwise it will miss one item in self.managed_queue.
if remove_item:
    self.managed_queue.remove(remove_item)
    
def Selection(self):
self.selection_flag = True
class RebalanceQueueItem():
def __init__(self, symbol_q):
# symbol/quantity collections
self.symbol_q = symbol_q
self.holding_period = 0
# Custom fee model
class CustomFeeModel(FeeModel):
def GetOrderFee(self, parameters):
fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
return OrderFee(CashAmount(fee, "USD"))