投资范围包括在NYSE、AMEX和NASDAQ交易的普通股。通过时间序列回归估算每个资产的情绪贝塔,因变量为高低情绪月份的历史超额回报,控制变量为同期的市场超额回报。回归使用10年滚动窗口,要求至少40个观测值,分析四个预设月份和实际情绪月份。每月根据过去2至5年的平均情绪贝塔将股票分为十分位数,计算等权重投资组合回报。策略在高情绪月份做多情绪贝塔最高的组合,做空最低的,低情绪月份则反向操作,每年执行四个月,并在每月末重新评估。

策略概述

投资范围包括在纽约证券交易所(NYSE)、美国证券交易所(AMEX)和纳斯达克(NASDAQ)交易的普通股。

我们通过时间序列回归估算每个资产的情绪贝塔(预测因子)。回归的因变量是资产在预设和实际的高情绪和低情绪月份中获得的历史超额回报(𝑋𝑅𝐸𝑇𝑖,MoodMonth),控制变量是同期的CRSP等权重市场超额回报(𝑋𝑅𝐸𝑇𝐴,MoodMonth)。该回归使用10年滚动窗口,并要求至少有40个观测值。回归每年包括八个月:四个预设的月份(1月、3月、9月和10月),以及四个实际的高情绪和低情绪月份(等权重市场回报最高的前两个月和最低的前两个月)。估算出的𝛽^Mood_𝑖_month称为月度情绪贝塔,衡量在这些历史情绪月份中资产(个股)的回报变化对整体市场回报变化的响应,具体为每1%的市场整体回报变化导致的资产回报变化。

每月,我们根据过去2年至5年的平均历史情绪贝塔(β^Mood_Month)将所有选定的股票分为十分位数,并计算等权重的投资组合回报。

基于情绪贝塔的多空投资组合在高情绪月份(1月和3月)做多情绪贝塔最高的十分位组合,做空情绪贝塔最低的十分位组合,而在低情绪月份(9月和10月)则反向操作。策略在每月末重新评估,每年共执行四个月。

策略合理性

情绪季节性假说认为,投资者情绪的季节性变化是整体和横截面回报季节性的部分原因。然而,如何用理性风险基础来解释这些发现还不清楚,这需要在横截面回报中存在可预测的、季节性的负风险溢价或市场贝塔或因子负载的季节性反转。现有文献表明,整体和横截面回报的季节性体现了因子溢价的季节性变化,尽管不一定是理性风险溢价。作者的贡献在于提出了这种季节性因子回报可预测性的来源之一,即季节性因子错误定价是由情绪的季节性变化引起的。

论文来源

Mood Beta and Seasonalities in Stock Returns [点击浏览原文]

<摘要>

现有研究记录了股票回报的横截面季节性——某些股票在同一日历月份或工作日的周期性超额表现。我们假设资产对投资者情绪的不同敏感性解释了这些效应,并暗示了其他季节性。与我们的假设一致,过去高或低情绪月份和工作日期间个股或股票投资组合的相对表现往往会在情绪相似的时期重复,并在情绪不一致的时期反转。此外,情绪敏感度更高的资产——即情绪贝塔更高的资产——在情绪上升时期获得更高回报,而在情绪下降时期获得更低回报。

回测表现

年化收益率11.42%
波动率15.71%
Beta0
夏普比率0.73
索提诺比率N/A
最大回撤N/A
胜率49%

完整python代码

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

class CrossSectionalMoodBetaStrategyinEquities(QCAlgorithm):

    def Initialize(self):
        self.SetStartDate(2000, 1, 1)
        self.SetCash(100000)

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

        self.regression_year_window:int = 5
        self.period:int = self.regression_year_window * 12
        self.mood_beta_period:int = 5
        self.historical_mood_beta:Dict[str, RollingWindow] = {}
                
        # four prespecified (January, March, September, and October) months
        self.high_mood_months:List[int] = [1, 3]
        self.low_mood_months:List[int] = [9, 10]

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

        self.leverage:int = 5
        self.coarse_count:int = 500
        self.selection_flag:bool = False
        self.rebalance_flag:bool = False
        self.UniverseSettings.Resolution = Resolution.Daily
        self.AddUniverse(self.CoarseSelectionFunction, self.FineSelectionFunction)
        self.Schedule.On(self.DateRules.MonthStart(self.market), self.TimeRules.AfterMarketOpen(self.market), self.Selection)

    def OnSecuritiesChanged(self, changes:SecurityChanges) -> None:
        for security in changes.RemovedSecurities:
            if security.Symbol in self.historical_mood_beta:
                del self.historical_mood_beta[security.Symbol]
            
        for security in changes.AddedSecurities:
            security.SetFeeModel(CustomFeeModel())
            security.SetLeverage(self.leverage)

    def CoarseSelectionFunction(self, coarse:List[CoarseFundamental]) -> List[Symbol]:
        if not self.selection_flag:
            return Universe.Unchanged
        self.selection_flag = False

        selected = [x.Symbol
            for x in sorted([x for x in coarse if x.HasFundamentalData and x.Market == 'usa'],
                key = lambda x: x.DollarVolume, reverse = True)[:self.coarse_count]]
        # selected:List[Symbol] = [x.Symbol for x in coarse if x.HasFundamentalData and x.Market == 'usa']

        return selected
                
    def FineSelectionFunction(self, fine:List[FineFundamental]) -> List[Symbol]:
        fine = [x.Symbol for x in fine if x.MarketCap != 0 and \
                    ((x.SecurityReference.ExchangeId == "NYS") or (x.SecurityReference.ExchangeId == "NAS") or (x.SecurityReference.ExchangeId == "ASE"))]

        history:DataFrame = self.History(fine + [self.market], start=self.Time.date() - relativedelta(months=self.period), end=self.Time.date())['close'].unstack(level=0)
        history = history.groupby(pd.Grouper(freq='M')).last()

        if len(history) >= self.period:
            history = history.iloc[-self.period:]
            history.index = history.index.to_pydatetime()
            
            asset_returns:DataFrame = history.pct_change().iloc[1:]

            # find best and worst performing months
            market_returns:DataFrame = asset_returns[self.market]
            asset_returns = asset_returns.loc[:, asset_returns.columns != self.market]    # drop market column
            year_range:List[int] = range(self.Time.year - (self.regression_year_window), self.Time.year-1)
            
            # four realized high and low mood months
            highest_performing_months:np.ndarray = list(np.array([market_returns[market_returns.index.year == year].nlargest(2).index.date for year in year_range]).reshape(-1))
            lowest_performing_months:np.ndarray = list(np.array([market_returns[market_returns.index.year == year].nsmallest(2).index.date for year in year_range]).reshape(-1))

            # four prespecified (January, March, September, and October)
            prespecified_months:List[datetime.date] = []
            for year in [market_returns[market_returns.index.year == year].index for year in year_range]:
                for date in year:
                    if date.month in [self.high_mood_months + self.low_mood_months]:
                        prespecified_months.append(date.date())

            selected_months:Set[datetime.date] = sorted(set(highest_performing_months + lowest_performing_months + prespecified_months), key=lambda x: x, reverse=False)
            
            # run regression
            x:np.ndarray = market_returns.loc[selected_months].values
            y:np.ndarray = asset_returns.loc[selected_months].values
            model = self.multiple_linear_regression(x, y)
            beta_values:np.ndarray = model.params[1]
            
            # store historical beta values
            beta_by_asset:Dict[str, float] = {}
            assets:List[str] = list(asset_returns.columns)

            for i, asset in enumerate(assets):
                asset_s:Symbol = self.Symbol(asset)
                beta_by_asset[asset_s] = beta_values[i]

            # sort by mean beta
            if len(beta_by_asset) >= self.quantile:
                sorted_by_beta:List[Symbol] = sorted(beta_by_asset, key=beta_by_asset.get, reverse=True)
                quantile:int = int(len(sorted_by_beta) / self.quantile)
                long:List[Symbol] = sorted_by_beta[:quantile]
                short:List[Symbol] = sorted_by_beta[-quantile:]

                # EW
                for asset in long:
                    self.weight[asset] = 1. / float(len(long))
                for asset in short:
                    self.weight[asset] = -1. / float(len(short))

        return list(self.weight.keys())

    def OnData(self, data:Slice) -> None:
        # monthly rebalance
        if not self.rebalance_flag:
            return
        self.rebalance_flag = False

        # long the highest decile and short the lowest mood beta decile during the high-mood months 
        # (January and March) and flip the long and short lags during the low-mood months (September and October)
        trade_direction:float = 0.
        if self.Time.month in self.high_mood_months:
            trade_direction = 1.
        elif self.Time.month in self.low_mood_months:
            trade_direction = -1.

        # rebalance
        for symbol, w in self.weight.items():
            self.SetHoldings(symbol, trade_direction*w)

    def Selection(self) -> None:
        # monthly rebalance
        self.rebalance_flag = True

        # yearly selection
        if self.Time.month == 1:
            self.selection_flag = True
            self.weight.clear()

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

# custom fee model
class CustomFeeModel(FeeModel):
    def GetOrderFee(self, parameters):
        fee:float = 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