投资范围包括在NYSE、AMEX和NASDAQ上市的所有普通股,价格必须高于5美元。使用Fama-French的49行业分类对公司进行分类,并根据行业分类和收益数据估算市场贝塔值。FIS(公司层面的投资者情绪因子)通过贝塔变化和贝塔偏离的乘积计算。每月末,根据FIS值将股票分为低(Low)和高(High)两组,执行多空策略,做空High组合并做多Low组合。所有投资组合按市值加权,并在月末重新平衡。

策略概述

投资范围包括在纽约证券交易所(NYSE)、美国证券交易所(AMEX)和纳斯达克(NASDAQ)上市的所有普通股,股票价格在组合形成时必须高于5美元。行业分类和收益数据用于估算行业市场贝塔值。根据Asness等人(2014)的方法,投资者将公司按照Fama-French的49行业分类标准进行分类。行业分类和等权重收益数据来自Kenneth French的数据库。贝塔值通过回归模型(公式1和2)进行计算。

FIS(公司层面的投资者情绪因子)通过贝塔变化和贝塔偏离的乘积计算得出(公式5)。贝塔变化是股票s在第t月的短期贝塔值与第t-1月的短期贝塔值之间的差额,除以股票s在第t-1月的长期贝塔值(公式4)。贝塔偏离是股票s在第t月的短期贝塔值与行业i在第t-1月的长期贝塔值之间的差额,除以行业i在第t-1月的长期贝塔值(公式3)。

每月末,根据FIS值将股票分为十组投资组合,投资组合Low(低)包含FIS值最低的股票,投资组合High(高)包含FIS值最高的股票。执行一个多空交易策略,即卖出(做空)High组合,买入(做多)Low组合。所有投资组合为市值加权,并在月末重新平衡。

策略合理性

FIS的负回报关系不受市场层面的投资者情绪指数的影响,表明FIS包含了不同于市场情绪的独特信息。负回报的预测性主要是由投资者的投机需求驱动的。乐观投资者交易的股票可能经历高度投机性购买。当公司的基本面信息公布时,高情绪股票的投机性购买往往会在随后的时期减少,导致价格回落。研究还发现,投资者情绪的负预测能力在噪音交易者参与度较高的股票中更强,且在经济衰退期间更为明显。这表明交易者可能导致股票定价过高,随后出现回调。

论文来源

A New Firm-level Investor Sentiment [点击浏览原文]

<摘要>

我们提出了一种基于公司与其行业同行市场贝塔差异的新投资者情绪度量方法。研究显示,这一指标与投资者情绪相一致。在横截面上,高情绪的股票在下个月显著跑输低情绪的股票。这种情绪-回报的负相关性对不同的风险调整模型、相关回报预测因子以及市场层面的情绪效应具有稳健性。我们发现,情绪高的股票经历了更多的投机性需求,导致了同时的高估并随后产生较低的股票回报。我们进一步显示,机构持股较低的股票和经济衰退期间,公司层面的情绪预测性更强,这与由噪音交易者驱动的错误定价理论预测一致。

回测表现

年化收益率6.17%
波动率10.46%
Beta0.035
夏普比率0.59
索提诺比率-0.048
最大回撤N/A
胜率51%

完整python代码

from AlgorithmImports import *
from pandas.core.frame import DataFrame
from typing import List, Dict
import statsmodels.api as sm
from dateutil.relativedelta import relativedelta
# endregion

class FirmLevelInvestorSentimentFactorInUS(QCAlgorithm):

    def Initialize(self) -> None:
        self.SetStartDate(2010, 1, 1)
        self.SetCash(100000)

        self.market:Symbol = self.AddEquity("SPY", Resolution.Daily).Symbol

        self.periods:List[int] = [3, 60]
        self.long_period:int = 60
        self.short_period:int = 3
        self.rolling_period:int = 2
        self.short_term_stock_beta:Dict[Symbol, RollingWindow] = {}
        self.long_term_stock_beta:Dict[Symbol, RollingWindow] = {}
        self.long_term_industry_beta:Dict[str, RollingWindow] = {}

        self.weight:Dict[Symbol, float] = {}
        self.quantile:int = 10
        self.min_share_price:int = 5

        self.leverage:int = 3
        self.fundamental_count:int = 500
        self.selection_flag:bool = False
        self.UniverseSettings.Resolution = Resolution.Daily
        self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
        self.AddUniverse(self.FundamentalSelectionFunction)
        self.Schedule.On(self.DateRules.MonthEnd(self.market), self.TimeRules.BeforeMarketClose(self.market), self.Selection)

    def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
        for security in changes.RemovedSecurities:
            if security.Symbol in self.short_term_stock_beta:
                del self.short_term_stock_beta[security.Symbol]
            if security.Symbol in self.long_term_stock_beta:
                del self.long_term_stock_beta[security.Symbol]

        for security in changes.AddedSecurities:
            security.SetFeeModel(CustomFeeModel())
            security.SetLeverage(self.leverage)

    def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
        # monthly selection
        if not self.selection_flag:
            return Universe.Unchanged

        selected:List[Fundamental] = [x for x in sorted([x for x in fundamental if x.HasFundamentalData and x.Market == 'usa' and x.AdjustedPrice >= self.min_share_price and \
            x.AssetClassification.MorningstarIndustryGroupCode != 0 and x.MarketCap != 0 and \
            (x.SecurityReference.ExchangeId == 'NYS') or (x.SecurityReference.ExchangeId == 'NAS') or (x.SecurityReference.ExchangeId == 'ASE')], 
            key = lambda x: x.DollarVolume, reverse = True)][:self.fundamental_count]

        history:DataFrame = self.History(list(map(lambda x: x.Symbol, selected)) + [self.market], start=self.Time.date() - relativedelta(months=self.long_period), end=self.Time.date())['close'].unstack(level=0)
        history = history.groupby(pd.Grouper(freq='M')).last()

        # sort stocks on industry numbers
        industries:Set[MorningstarIndustryGroupCode] = set([x.AssetClassification.MorningstarSectorCode for x in selected])
        grouped_industries:Dict[MorningstarIndustryGroupCode, List[Symbol]] = { industry : [stock.Symbol for stock in selected if stock.AssetClassification.MorningstarSectorCode == industry] for industry in industries }

        # get stock returns and clean up the data
        if len(history) >= self.long_period:
            history = history.iloc[-self.long_period:]
            history.index = history.index.to_pydatetime()

            asset_returns:DataFrame = history.pct_change().iloc[1:]

            stock_returns = asset_returns.loc[:, asset_returns.columns != self.market].tail(-1)
            asset_returns['spy_lag'] = asset_returns[self.market].shift(1)
            asset_returns = asset_returns.iloc[1:]

            industry_df:DataFrame = pd.DataFrame(index=asset_returns.index)

            for industry_code in grouped_industries:
                ind_symbols:List[Symbol] = grouped_industries[industry_code]
                if all(symbol in asset_returns.columns for symbol in ind_symbols):
                    industry_df[industry_code] = asset_returns[ind_symbols].mean(axis=1)

            # run industry regression
            x:np.ndarray = asset_returns[[self.market, 'spy_lag']].values
            y:np.ndarray = industry_df.values
            model = self.multiple_linear_regression(x, y)
            beta_values:np.ndarray = sum(model.params[1:])

            for i, industry in enumerate(industry_df):
                if industry not in self.long_term_industry_beta:
                    self.long_term_industry_beta[industry] = RollingWindow[float](self.rolling_period)
                self.long_term_industry_beta[industry].Add(beta_values[i])

            # run stock regression
            for period in self.periods:
                x:np.ndarray = asset_returns[[self.market, 'spy_lag']][-period:].values
                y:np.ndarray = stock_returns[-period:].values
                model = self.multiple_linear_regression(x, y)
                beta_values:np.ndarray = sum(model.params[1:])

                for i, asset in enumerate(stock_returns):
                    asset_s:Symbol = self.Symbol(asset)
    
                    # fill rolling windows with data
                    if asset_s not in self.short_term_stock_beta or asset_s not in self.long_term_stock_beta:
                        if beta_values[i] != 0 and beta_values[i] is not None:
                            if period == self.periods[0]:
                                self.short_term_stock_beta[asset_s] = RollingWindow[float](self.rolling_period)
                            else:
                                self.long_term_stock_beta[asset_s] = RollingWindow[float](self.rolling_period)

                    if beta_values[i] != 0 and beta_values[i] is not None:
                        if period == self.periods[0]:
                            self.short_term_stock_beta[asset_s].Add(beta_values[i])
                        else:
                            self.long_term_stock_beta[asset_s].Add(beta_values[i])

        beta_by_symbol:Dict[Fundamental, float] = {}

        # FIS calculation
        for stock in selected:
            symbol:Symbol = stock.Symbol

            if symbol in self.short_term_stock_beta or symbol in self.long_term_stock_beta:
                if self.short_term_stock_beta[symbol].IsReady and self.long_term_stock_beta[symbol].IsReady:
                    bc:float = (self.short_term_stock_beta[symbol][0] - self.short_term_stock_beta[symbol][1]) / self.long_term_stock_beta[symbol][1]

                    for industry in grouped_industries:
                        if industry in self.long_term_industry_beta:
                            if symbol in grouped_industries[industry] and self.long_term_industry_beta[industry].IsReady:
                                dc:float = (self.short_term_stock_beta[symbol][0] - self.long_term_industry_beta[(industry)][1]) / self.long_term_industry_beta[industry][1]
                                beta_by_symbol[stock] = bc * dc

        # sort by beta and divide to upper decile and lower decile
        if len(beta_by_symbol) >= self.quantile:
            sorted_by_beta:List[Fundamental] = sorted(beta_by_symbol, key=beta_by_symbol.get)
            quantile:int = int(len(sorted_by_beta) / self.quantile)
            long:List[Fundamental] = sorted_by_beta[:quantile]
            short:List[Fundamental] = sorted_by_beta[-quantile:]

            # calculate weights based on values
            for i, portfolio in enumerate([long, short]):
                mc_sum:float = sum(list(map(lambda stock: stock.MarketCap, portfolio)))
                for stock in portfolio:
                    self.weight[stock.Symbol] = ((-1)**i) * stock.MarketCap / mc_sum

        return list(map(lambda x: x.Symbol, selected))
    
    def OnData(self, data: Slice) -> None:
        # monthly rebalance
        if not self.selection_flag:
            return
        self.selection_flag = False

        portfolio:List[PortfolioTarget] = [PortfolioTarget(symbol, w) for symbol, w in self.weight.items() if symbol in data and data[symbol]]
        self.SetHoldings(portfolio, True)

        self.weight.clear()

    def Selection(self) -> None:
        self.selection_flag = True

    def multiple_linear_regression(self, x:np.ndarray, y:np.ndarray):
        # x:np.ndarray = np.array(x).T
        x = sm.add_constant(x, prepend=True)
        result = sm.OLS(endog=y, exog=x).fit()
        return result

# Custom fee model.
class CustomFeeModel(FeeModel):
    def GetOrderFee(self, parameters):
        fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
        return OrderFee(CashAmount(fee, "USD"))

Leave a Reply

Discover more from Quant Buffet

Subscribe now to keep reading and get access to the full archive.

Continue reading