Quant Buffet放轻松,别过度思虑

基于有限投资者关注度的谷歌搜索策略

登录后收藏

学术论文

In Search of Alpha - Trading on Limited Investor Attention

作者寻找阿尔法——基于有限投资者关注度的交易 [点击查看论文]

机构
  • DEWHU – Otto Beisheim School of Management
  • ?WHU - Otto Beisheim School of Management
  • Goethe Institute
  • DEUniversität Hamburg
  • DEGoethe University Frankfurt
  • ?Goethe University Frankfurt - House of Finance
  • ?University of Hamburg
  • ?WHU Otto Beisheim Graduate School of Management

策略概要

该策略涉及具有可用谷歌搜索量(GSV)数据的标普500指数股票。投资者通过将第t周的GSV与过去八周的GSV中位数进行比较,计算异常搜索量(ASV)。ASV的公式为:ASVt = ln[GSVt] - ln[Med(GSVt-1,…,GSVt-8)]。ASV每周更新。投资者做多上周关注度低或不变的股票(ASVt-1 ≤ 0)。股票等权重,投资组合每周重新平衡。该策略旨在利用异常搜索量模式和市场关注动态获利。

II. 策略合理性

当与公司相关的信息在投资者关注度较低的时期未能完全反映在股价中时,就会出现市场低效率。例如,基本面的积极变化可能不会立即影响股价,导致股票被低估。做多此类股票可以在关注度增加并推高股价时产生利润。此外,低关注度与较低的风险和波动性相关,与关注度较高的股票相比,股票波动性较低,回报更稳定。这种低效率为投资者提供了利用低估股票获利的机会。

回测表现

波动率21.4%
夏普比率0.83
索提诺比率0.518
胜率66%

完整 Python 代码

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