Quant Buffet放轻松,别过度思虑

内部交易与动量结合

登录后收藏

学术论文

Momentum and Insider Trading

作者动量与内部交易 [点击查看论文]

机构
  • California State University, Chico

策略概要

该策略针对纽约证券交易所 (NYSE)、美国证券交易所 (AMEX) 和纳斯达克 (NASDAQ) 的股票,但排除股价低于 5 美元、市值处于纽约证券交易所最低十分位、或账面权益缺失/非正值的股票。每月,投资者计算净内部需求 (NID),即过去六个月的内部购买量减去销售量,并归一化为流通股数。股票根据六个月收益率分为“赢家”(前十分位)和“输家”(后十分位)投资组合,并进一步按内部交易活动分为“买入”(NID 为正)、“卖出”(NID 为非正值)和“沉默”(无交易活动)类别。该策略做多具有正 NID 的赢家股票,并做空处于沉默投资组合中的输家股票。所有头寸采用等权重配置,持有期为 12 个月,并按 1/12 比例进行每月再平衡。

II. 策略合理性

学术研究表明,只有当内部交易与过去的回报一致时,短期动量才会持续——积极的内部活动支持赢家,而消极的活动证实输家。这种动量源于投资者对内部交易信息反应不足。然而,由于潜在的监管和诉讼风险,内部人士很谨慎,尤其是对于销售而言,这造成了他们行为的不对称性。当掌握负面私人信息并预期价格大幅下跌时,内部人士通常会避免出售,以避免法律审查。相反,当他们掌握有关公司的正面信息时,他们更有可能进行交易,从而加强了观察到的内部活动与动量驱动的股票表现之间的联系。

回测表现

胜率28%

完整 Python 代码

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"))