Institutional Ownership Effect
Log in to collectAcademic paper
Strategy in a nutshell
Targets NYSE, AMEX, and NASDAQ stocks above $1. Quarterly, stocks are sorted by institutional ownership turnover (InstTurnIndiv). Go long on the lowest decile (least turnover) and short the highest decile (most turnover). Portfolio equally weighted and rebalanced quarterly.
Economic rationale
Stocks with stable institutional ownership tend to have higher expected returns, as significant institutional-to-retail flows reduce potential gains.
Backtest performance
Annualised return15%
Volatility20.1%
Beta-0.022
Sharpe ratio0.71
Sortino ratio-0.016
Win rate49%
Full Python code
from AlgorithmImports import *
#endregion
class InstitutionalOwnershipEffect(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2005, 1, 1)
self.SetCash(100_000)
self.dates: List[datetime.date] = []
self.institutional_ownership: Dict[Symbol, Dict] = {}
self.data: Dict[Symbol, SymbolData] = {}
self.period: int = 3 * 8 * 21 # Eight quarter period
self.one_quarter_data: Dict[Symbol, List[float]] = {} # Storing one quarter data about each stock from our universe
self.eight_quarter_data: Dict[Symbol, RollingWindow] = {} # Storing eight quarter data about each stock from our universe
self.quantile: int = 10
self.leverage: int = 5
self.last_investment_date = None
self.symbol: Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
self.sp100_stocks: List[Symbol] = [] # 'AGN', 'UTX', 'BRKB' # No data about institutional ownership
csv_string_file: str = self.Download('data.quantpedia.com/backtesting_data/economic/institutional_ownership/institutional_ownership_in_millions.csv')
lines: List[str] = csv_string_file.split('\r\n')
for i in range(len(lines)):
line_split: List[str] = lines[i].split(',')
if i == 0: # First row are headers of columns
for j in range(1, len(line_split)): # Take all headers, which are stock tickers
ticker: str = line_split[j]
symbol: Symbol = self.AddEquity(ticker, Resolution.Daily).Symbol
# Warmup volume data.
self.data[symbol] = SymbolData(self.period)
history: DataFrame = self.History(symbol, self.period, Resolution.Daily)
if history.empty:
self.Log(f"Not enough data for {symbol} yet")
continue
volumes: Series = history.loc[symbol].volume
for time, volume in volumes.items():
self.data[symbol].update(volume)
self.one_quarter_data[symbol] = []
self.eight_quarter_data[symbol] = RollingWindow[float](8)
self.institutional_ownership[symbol] = {} # Create dictionary for all institutional ownership data
# Subscript current stock and append symbol to universe, which we will use for this strategy
self.sp100_stocks.append(symbol)
else:
date: datetime.date = datetime.strptime(line_split[0], "%Y-%m-%d").date()
self.dates.append(date) # Create list of dates for institutional annoucements
for j, symbol in zip(range(1, len(line_split)), self.sp100_stocks):
# Based on symbol store date and institutional ownership value as a nested dictionary
# In nested dictionary date figures as a key and institutional ownership as a value
self.institutional_ownership[symbol][date] = (line_split[j])
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
self.count_month: int = 1
self.selection_flag: bool = False
self.Schedule.On(self.DateRules.MonthEnd(self.symbol), self.TimeRules.AfterMarketOpen(self.symbol), self.Selection)
def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
for security in changes.AddedSecurities:
security.SetFeeModel(CustomFeeModel())
security.SetLeverage(self.leverage)
def OnData(self, slice: Slice) -> None:
date = None
inst_turn_in_div: Dict[Symbol, float] = {}
current_date: datetime.date = self.Time.date()
day_before: datetime.date = (self.Time - timedelta(days=1)).date()
two_days_before: datetime.date = (self.Time - timedelta(days=2)).date()
three_days_before: datetime.date = (self.Time - timedelta(days=3)).date()
for symbol in self.sp100_stocks:
# Update daily volume data.
if symbol in slice:
if slice[symbol]:
self.data[symbol].update(slice[symbol].Volume)
# Institutional ownership could be annouced on weekend. This prevents missing it.
# Without three days look ahead it was missing some annoucements
if (current_date in self.dates) and (current_date != self.last_investment_date):
self.last_investment_date = current_date
date = current_date
elif (day_before in self.dates) and (day_before != self.last_investment_date):
self.last_investment_date = day_before
date = day_before
elif (two_days_before in self.dates) and (two_days_before != self.last_investment_date):
self.last_investment_date = two_days_before
date = two_days_before
elif (three_days_before in self.dates) and (three_days_before != self.last_investment_date):
self.last_investment_date = three_days_before
date = three_days_before
# If institutional ownership was announced store institutional_ownership value about each stock from our universe
if date in self.dates:
if self.institutional_ownership[symbol][date] != '':
self.one_quarter_data[symbol].append(float(self.institutional_ownership[symbol][date]))
if not self.selection_flag:
continue
absolute_change = None
if len(self.one_quarter_data[symbol]) >= 2:
absolute_change: float = self.one_quarter_data[symbol][-1] - self.one_quarter_data[symbol][0]
total_one_quarter: float = sum(self.one_quarter_data[symbol])
self.eight_quarter_data[symbol].Add(total_one_quarter)
# If eight quarter institutional ownership data are ready for chosen stock, then trade
if self.data[symbol].is_ready() and self.eight_quarter_data[symbol].IsReady:
if symbol != 'ADP':
inst_turn_in_div_num: float = absolute_change / self.data[symbol].total_volume()
total_inst_shares: float = sum([x for x in self.eight_quarter_data[symbol]])
inst_turn_in_div_num: float = inst_turn_in_div_num / total_inst_shares
inst_turn_in_div[symbol] = inst_turn_in_div_num
self.one_quarter_data[symbol].clear()
if not self.selection_flag:
return
self.selection_flag = False
long: List[Symbol] = []
short: List[Symbol] = []
if len(inst_turn_in_div) >= self.quantile:
quantile: int = int(len(inst_turn_in_div) / self.quantile)
sorted_by_inst_turn_in_div = [x[0] for x in sorted(inst_turn_in_div.items(), key=lambda item: item[1])]
# long the lowest ‘InstTurnIndiv’ decile
long = sorted_by_inst_turn_in_div[:quantile]
# short the highest ‘InstTurnIndiv’ decile
short = sorted_by_inst_turn_in_div[-quantile:]
# Trade execution.
invested: List[Symbol] = [x.Key for x in self.Portfolio if x.Value.Invested]
for symbol in invested:
if symbol not in long + short:
self.Liquidate(symbol)
for symbol in long:
self.SetHoldings(symbol, 1 / len(long))
for symbol in short:
self.SetHoldings(symbol, -1 / len(short))
def Selection(self) -> None:
if self.count_month == 3: # The portfolio is rebalance quarterly
self.selection_flag = True
self.count_month = 1
else:
self.count_month += 1
class SymbolData():
def __init__(self, period: int) -> None:
self._volumes: RollingWindow = RollingWindow[float](period)
def update(self, volume) -> None:
self._volumes.Add(volume)
def is_ready(self) -> bool:
return self._volumes.IsReady
def total_volume(self) -> float:
return sum(list(self._volumes))
class CustomFeeModel(FeeModel):
def GetOrderFee(self, parameters):
fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
return OrderFee(CashAmount(fee, "USD"))