根据Glassdoor员工满意度排名进行股票交易的策略,对员工满意度排名相似的公司中表现优异的公司做多,对表现不佳的公司做空,并在一年内以1/12的比例每月重新平衡。

I. 策略概述

该策略聚焦于Glassdoor员工满意度(ES)指数中的股票。每月,投资者计算目标公司在ES排名中前后20位的40家同业公司的过去一个月或一年的市值加权平均回报率。对拥有表现最佳同业的200家公司进行多头操作,对拥有表现最差同业的200家公司进行空头操作。股票持有一年,每月根据更新的ES排名对投资组合的1/12进行重新平衡。此策略利用同业表现识别定价错误的股票,并将投资与员工满意度趋势对齐。

II. 策略合理性

高员工满意度(ES)带来多个优势。首先,它提高了员工的工作积极性,促使员工更认同工作、努力付出并贡献创新想法。其次,它增强了公司的吸引力,使公司在跨行业人才竞争中更具吸引力。第三,它提高了员工的留任率,帮助公司在经济冲击中更具韧性。在经济衰退期间,具有高ES的公司通常表现更佳,原因是员工更加积极且人员流失率较低。

针对这种异常现象,研究探讨了三种可能的解释:投资者注意力不足、套利限制和信息复杂性。尽管三者均获得支持,但投资者注意力不足是主要因素。证据表明,获得较少关注的公司(股票换手率低、分析师覆盖少、机构持股率低)对同业公司ES相关信息的反应较慢,从而导致股价反应滞后。

III. 论文来源

Return Cross-Predictability in Firms with Similar Employee Satisfaction [点击浏览原文]

<摘要>

我们研究了具有相似员工满意度(SES)的公司回报的可预测性,使用了来自Glassdoor的新公司排名数据。我们发现,SES公司同业的回报对目标公司回报具有预测能力。基于SES公司同业滞后回报排序的多空投资组合每月实现显著的Fama和French(2018)六因子Alpha收益率为135个基点。此结果不同于行业内或公司间的动量效应,也无法通过基于风险的解释加以说明。我们的测试表明,投资者注意力有限是公司对其SES公司回报反应不足的主要原因。

IV. 回测表现

年化收益率12.95%
波动率15.11%
Beta0.01
夏普比率0.86
索提诺比率-0.421
最大回撤N/A
胜率45%

V. 完整python代码

from AlgorithmImports import *
import numpy as np
from typing import List, Dict
from pandas.core.frame import dataframe
from pandas.core.series import Series
from dataclasses import dataclass, field
#endregion
class ReturnCrossPredictabilityInFirmsWithSimilarEmployeeSatisfaction(QCAlgorithm):
    def Initialize(self) -> None:
        self.SetStartDate(2010, 1, 1) # First ES data are for 2009
        self.SetCash(100000)
        
        symbol: Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
        
        self.holding_period: int = 12 # Holding portfolio for 12 months 
        self.invest_count: int = 5 # Long n stocks and short n stocks
        self.period: int = 21 # Storing n daily prices
        self.leverage: int = 5
        self.min_share_price: int = 5
        
        self.selected_stock_count: int = 2 # Calculate the value-weighted average of n stock over and under current stock
        
        self.select_current_stock: bool = True # If this flag is True, calculation of value-weighted average will include current stock too
        
        self.data: Dict[Symbol, SymbolData] = {} # Storing daily prices of stocks
        self.employee_satisfaction: Dict[str, Dict[int, float]] = {} # Storing stock's ratings for specific years
        
        self.managed_queue: List[RebalanceQueueItem] = []
        
        # Download companies ratings for each year
        csv_string_file: str = self.Download('data.quantpedia.com/backtesting_data/index/EMPLOYEE_SATISFACTION.csv')
        lines: List[str] = csv_string_file.split('\r\n')
        for line in lines[1:]: # Skip header
            line_split: List[str] = line.split(';')
            date: datetime.date = datetime.strptime(line_split[0], "%d.%m.%Y").date()
            
            company_ticker: str = line_split[1]
            rating: str = line_split[2]
            
            # Create dictionary for each company ticker
            if company_ticker not in self.employee_satisfaction:
                self.employee_satisfaction[company_ticker] = {}
            
            # Under company ticker and year store rating
            self.employee_satisfaction[company_ticker][date.year] = rating
            
        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]:
        for stock in fundamental:
            symbol: Symbol = stock.Symbol
            
            # Store stock price if it is in universe
            if symbol.Value in self.employee_satisfaction and symbol in self.data:
                self.data[symbol].update(stock.AdjustedPrice)
        
        # Rebalance monthly
        if not self.selection_flag:
            return Universe.Unchanged
        
        # Universe will be created based on stock tickers in self.employee_satisfaction
        selected: List[Fundamental] = [
            x for x in fundamental 
            if x.HasFundamentalData 
            and x.MarketCap != 0 
            and x.Market == 'usa' 
            and x.Symbol.Value in self.employee_satisfaction 
            and x.Price > self.min_share_price
            ]
        stock_ratings = {} # Storing objects of StockRatings
        market_cap: Dict[Symbol, float] = {} # Storing stocks market capitalization
        
        prev_year = self.Time.year - 1 # Getting stocks ratings for previous year
        
        for stock in selected:
            symbol: Symbol = stock.Symbol
            if symbol not in self.data:
                # Warmup RollingWindow with History for current stock symbol
                self.data[symbol] = SymbolData(self.period)
                history: dataframe = self.History(symbol, self.period, Resolution.Daily)
                # Continue only if history isn't empty
                if history.empty:
                    self.Log(f"Not enough data for {symbol} yet.")
                    continue
                
                # Get and store stock's closes
                closes: Series = history.loc[symbol].close
                for _, close in closes.items():
                    self.data[symbol].update(close)
            if not self.data[symbol].is_ready():
                continue
            # Check if stock has rating for previous year
            if prev_year in self.employee_satisfaction[symbol.Value]:
                # Get stock's rating for last year
                rating: str = self.employee_satisfaction[symbol.Value][prev_year]
                
                if rating not in stock_ratings:
                    stock_ratings[rating] = StockRatings()
                    
                # Add new stock's symbol to stocks dictionary
                stock_ratings[rating].stocks[symbol] = stock.MarketCap
                
                # Store stock's market capitalization
                market_cap[symbol] = stock.MarketCap
        
        # Sort stocks by thier ES ratings and stocks with same rating sort by market capitalization
        sorted_by_rating: List[Symbol] = self.SortStocks(stock_ratings)
        
        # Storing stocks value weight average
        value_weight_avg: Dict[Symbol, float] = {}
        
        # Exclude first and last n stocks
        for i in range(self.selected_stock_count, len(sorted_by_rating) - self.selected_stock_count):
            current_stock_symbol: Symbol = sorted_by_rating[i]
            
            # Select current stock, n stocks with smaller rating and n stocks with larger rating
            selected_stocks: List[Symbol] = sorted_by_rating[i-self.selected_stock_count:i+self.selected_stock_count + 1]
            # Calculate and store current stock's value weight average
            value_weight_avg[current_stock_symbol] = self.ValueWeightAverage(current_stock_symbol, selected_stocks, market_cap)
        
        # Sort stocks by value weight average
        sorted_by_value_weight_avg: List[Symbol] = [x[0] for x in sorted(value_weight_avg.items(), key=lambda item: item[1])]
        
        # Buy stocks of n firms, which have the best performing peers over the last period
        long: List[Symbol] = sorted_by_value_weight_avg[-self.invest_count:]
        # Short stocks of n firms, which have the worst-performing peers
        short: List[Symbol] = sorted_by_value_weight_avg[:self.invest_count]
        
        if len(long) != 0 and len(short) != 0:
            # Calculate portfolio weight for long and short part
            long_w = self.Portfolio.TotalPortfolioValue / self.holding_period / len(long)
            short_w = self.Portfolio.TotalPortfolioValue / self.holding_period / len(short)
            
            long_symbol_q = [(x, np.floor(long_w / self.data[x].last_price)) for x in long]
            short_symbol_q = [(x, -np.floor(short_w / self.data[x].last_price)) for x in short]
            
            self.managed_queue.append(RebalanceQueueItem(long_symbol_q + short_symbol_q))
        
        return long + short
    def OnData(self, data: Slice) -> None:
        if not self.selection_flag:
            return
        self.selection_flag = False
        
        remove_item: Union[None, RebalanceQueueItem] = None
        
        # Rebalance portfolio
        for item in self.managed_queue:
            if item.holding_period == self.holding_period: # All portfolio parts are held for n months
                for symbol, quantity in item.opened_symbol_q:
                    self.MarketOrder(symbol, -quantity)
                            
                remove_item = item
            
            # Trade execution    
            if item.holding_period == 0: # All portfolio parts are held for n months
                opened_symbol_q: List[Tuple[Symbol, int]] = []
                
                for symbol, quantity in item.opened_symbol_q:
                    if symbol in data and data[symbol]:
                        self.MarketOrder(symbol, quantity)
                        opened_symbol_q.append((symbol, quantity))
                            
                # Only opened orders will be closed        
                item.opened_symbol_q = opened_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) -> None:
        self.selection_flag = True
        
    def SortStocks(self, stocks_ratings) -> List[Symbol]:
        sorting_result: List[Symbol] = []
        
        # Sort dictionary by key values
        sorted_by_rating: List[StockRatings] = [x[1] for x in sorted(stocks_ratings.items(), key=lambda item: item[0], reverse=True)]
        
        # Sort stocks with same rating by market capitalizations
        for value in sorted_by_rating:
            # Sort dictionary of stocks with same ratings by their market capitalization
            sorted_by_cap = [x[0] for x in sorted(value.stocks.items(), key=lambda item: item[1], reverse=True)]
            
            sorting_result += sorted_by_cap
            
        return sorting_result
        
    def ValueWeightAverage(self, 
                        current_stock_symbol: Symbol, 
                        stocks: List[Symbol], 
                        market_cap: Dict[Symbol, float]) -> float:
        value_weights: List[float] = []
        # Sum total market capitalization of needed stocks
        total_cap: float = sum([market_cap[x] for x in stocks if x != current_stock_symbol or self.select_current_stock])
        
        for symbol in stocks:
            if symbol != current_stock_symbol or self.select_current_stock:
                # Calculate stock performance on whole period
                performance: float = self.data[symbol].performance()
                # Calculate current stock weight
                weight: float = market_cap[symbol] / total_cap
                # Calculate value weight return
                value_weights.append(weight * performance)
        
        # Return value-weighted average    
        return np.mean(value_weights)
        
@dataclass
class RebalanceQueueItem():
    # symbol/quantity collections
    opened_symbol_q: Symbol 
    holding_period: float = 0
class StockRatings():
    def __init__(self) -> None:
        self.stocks: Dict[str, int] = {}
        
class SymbolData():
    def __init__(self, period: int) -> None:
        self.closes: RollingWindow = RollingWindow[float](period)
        self.last_price: Union[None, float] = None
        
    def update(self, close: float) -> None:
        self.closes.Add(close)
        self.last_price = close
        
    def is_ready(self) -> None:
        return self.closes.IsReady
        
    def performance(self) -> float:
        closes: List[float] = [x for x in self.closes]
        return (closes[0] - closes[-1]) / closes[-1]
        
# 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"))




发表评论

了解 Quant Buffet 的更多信息

立即订阅以继续阅读并访问完整档案。

继续阅读