
The strategy uses Google Search Volume (GSV) to calculate Abnormal Search Volume (ASV), going long on S&P 500 stocks with low or unchanged attention. The portfolio is equally weighted and rebalanced weekly.
I. STRATEGY IN A NUTSHELL
This strategy trades S&P 500 stocks using Google Search Volume (GSV) data. Abnormal Search Volume (ASV) is calculated weekly by comparing current GSV to the median of the past eight weeks. The investor goes long on stocks with low or unchanged ASV, equally weighting positions and rebalancing weekly.
II. ECONOMIC RATIONALE
Stocks with low attention may be underpriced as market participants underreact to new information. When attention increases, prices adjust, generating potential profits. Low-attention stocks also tend to be less volatile, offering more stable returns.
III. SOURCE PAPER
In Search of Alpha – Trading on Limited Investor Attention [Click to Open PDF]
Konstantin Storms, Julia Kapraun and Markus Rudolf.WHU – Otto Beisheim School of Management.University of Hamburg; Goethe University Frankfurt – House of Finance.WHU Otto Beisheim Graduate School of Management.
<Abstract>
In this study we develop a trading strategy that exploits limited investor attention. Trading signals for US S&P 500 stocks are derived from Google Search Volume data, taking a long position if investor attention for the corresponding security was abnormally low in the past week. Our strategy generates 19% average annual return and thereby outperforms a simple market buy-and-hold strategy. After controlling for the well-known risk factors, a significant alpha (abnormal return) of 10% p.a. remains. Returns are sufficiently large to cover transaction costs.


IV. BACKTEST PERFORMANCE
| Annualised Return | 19.3% |
| Volatility | 21.4% |
| Beta | 0.729 |
| Sharpe Ratio | 0.83 |
| Sortino Ratio | 0.518 |
| Maximum Drawdown | N/A |
| Win Rate | 66% |
V. FULL PYTHON CODE
from AlgorithmImports import *
import numpy as np
from typing import List, Dict
#endregion
class GoogleSearchStrategyBasedOnLimitedInvestorAttention(QCAlgorithm):
def Initialize(self) -> None:
self.SetStartDate(2004, 1, 1) # Google search data are since 2004
self.SetCash(100000)
self.data: Dict[Symbol, List[float]] = {} # Storing search values about each stock in list
self.selected: Dict[str, Symbol] = {} # Storing stocks, which will be traded
self.tickers: List[str] = [] # Storing S&P100 tickers
self.symbols: List[Symbol] = [] # Storing symbols, to get search values about stocks
self.last_ASV: Dict[Symbol, float] = {} # Storing last Abnormal Search Volume for each stock
self.period: int = 8 # 8 months of search values
self.leverage: int = 5
# Load csv with S&P100 tickers
csv_string_file: str = self.Download('data.quantpedia.com/backtesting_data/sp100.csv')
line: List[str] = csv_string_file.split('\r\n')
line_split: List[str] = line[0].split(';')
for ticker in line_split:
self.tickers.append(ticker)
ticker: str = ticker
# Subscribe to QuantpediaGoogleSearch with csv name
symbol: Symbol = self.AddData(QuantpediaGoogleSearch, ticker, Resolution.Daily).Symbol
# Add subscribed symbol to self.symbols
self.symbols.append(symbol)
# Create list for each subcribed symbol
self.data[symbol] = []
symbol: Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
self.selection_flag: bool = False
self.settings.daily_precise_end_time = False
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
self.UniverseSettings.Resolution = Resolution.Daily
self.AddUniverse(self.FundamentalSelectionFunction)
self.Schedule.On(self.DateRules.MonthStart(symbol), self.TimeRules.AfterMarketOpen(symbol), self.Selection)
def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
for security in changes.AddedSecurities:
security.SetFeeModel(CustomFeeModel())
security.SetLeverage(self.leverage)
def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
# Rebalance monthly
if not self.selection_flag:
return Universe.Unchanged
# Select S&P 100 stocks
self.selected = {x.Symbol.Value: x.Symbol for x in fundamental if x.Symbol.Value in self.tickers}
return list(self.selected.values())
def OnData(self, data: Slice) -> None:
# Store search data for each S&P100 stock
for symbol in self.symbols:
if symbol in data and data[symbol]:
value: float = data[symbol].Value
self.data[symbol].append(value)
# Rebalance monthly
if not self.selection_flag:
return
self.selection_flag = False
long: List[Symbol] = []
GSV_symbols_last_update_date: datetime.date = QuantpediaGoogleSearch.get_last_update_date()
for symbol in self.symbols:
if self.Securities[symbol].GetLastData() and self.Time.date() <= GSV_symbols_last_update_date[symbol]:
if len(self.data[symbol]) > self.period: # Wait until we have more than self.period months of data
search_volumes: List[int] = self.data[symbol]
# Get largest search value
max_value: int = max(search_volumes)
current_asv: float = 0.
if max_value > 0:
# Calculate last search volume
last_search_volume: int = search_volumes[-1] / max_value
# Get and calculate last self.period search volumes
last_n_search_volume: int = np.median([x / max_value for x in search_volumes[-self.period + 1:][:-1]])
# NOTE: We can't work with infinite numbers,
# which will be created by log of last_search_volume and log of last_n_search_volume
if last_search_volume != 0 and last_n_search_volume != 0:
# Need to make log from last_search_volume and last_n_search_volume for accurate mathematical form
last_search_volume: float = np.log(last_search_volume)
last_n_search_volume: float = np.log(last_n_search_volume)
# In mathematical form, it is: ASVt = ln [GSVt] – ln [Med (GSVt-1, …,GSVt-8)]
current_asv: float = last_search_volume - last_n_search_volume
# Check if symbol has last ASV
if symbol in self.last_ASV:
# Go long if last week’s attention for the stock was abnormally low or remains unchanged, i.e. ASVt-1 ≤ 0.
if current_asv == self.last_ASV[symbol] or self.last_ASV[symbol] <= 0:
long.append(symbol)
# Change last ASV
self.last_ASV[symbol] = current_asv
# If long list is empty liquidate all stocks
if len(long) == 0:
self.Liquidate()
return
# Trade execution.
targets: List[PortfolioTarget] = []
for symbol in long:
if symbol.Value in self.selected:
# Get QC symbol for trade
symbol: Symbol = self.selected[symbol.Value]
if symbol in data and data[symbol]:
targets.append(PortfolioTarget(symbol, 1 / len(long)))
self.SetHoldings(targets, True)
def Selection(self) -> None:
self.selection_flag = True
# Quantpedia data.
# NOTE: IMPORTANT: Data order must be ascending (datewise)
# NOTE: IMPORTANT: Name of the csv file has to be in upper case
class QuantpediaGoogleSearch(PythonData):
_last_update_date:Dict[Symbol, datetime.date] = {}
@staticmethod
def get_last_update_date() -> Dict[Symbol, datetime.date]:
return QuantpediaGoogleSearch._last_update_date
def GetSource(self, config: SubscriptionDataConfig, date: datetime, isLiveMode: bool) -> SubscriptionDataSource:
return SubscriptionDataSource("data.quantpedia.com/backtesting_data/google_search/{0}_STOCK.csv".format(config.Symbol.Value), SubscriptionTransportMedium.RemoteFile, FileFormat.Csv)
def Reader(self, config: SubscriptionDataConfig, line: str, date: datetime, isLiveMode: bool) -> BaseData:
data = QuantpediaGoogleSearch()
data.Symbol = config.Symbol
if not line[0].isdigit(): return None
split: List[str] = line.split(';')
data.Time = datetime.strptime(split[0], "%d.%m.%Y") + timedelta(days=1)
data['value'] = float(split[1])
data.Value = float(split[1])
if config.Symbol not in QuantpediaGoogleSearch._last_update_date:
QuantpediaGoogleSearch._last_update_date[config.Symbol] = datetime(1,1,1).date()
if data.Time.date() > QuantpediaGoogleSearch._last_update_date[config.Symbol]:
QuantpediaGoogleSearch._last_update_date[config.Symbol] = data.Time.date()
return data
# Custom fee model
class CustomFeeModel(FeeModel):
def GetOrderFee(self, parameters: OrderFeeParameters) -> OrderFee:
fee: float = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
return OrderFee(CashAmount(fee, "USD"))