投资范围包括在NYSE、AMEX和NASDAQ交易的普通股。历史月份和预测月份的投资者情绪不一致时,历史季节性回报会负向预测未来回报。例如,1月和3月表现良好的股票在9月和10月表现不佳,反之亦然。每年1月和3月,根据前2至5年的回报将股票分为十分位数,9月和10月则相反。多空投资组合做多表现最差的十分位股票,做空表现最好的股票,投资组合为等权重。

策略概述

投资范围包括在纽约证券交易所(NYSE)、美国证券交易所(AMEX)和纳斯达克(NASDAQ)交易的普通股。当历史月份和预测月份的投资者情绪不一致时,股票相对于其他证券的历史季节性回报是其未来相对季节性回报的负向预测指标。

例如,1月和3月是积极情绪月份,因此在1月和3月表现良好的股票会在9月和10月(情绪不一致月份)表现不佳。同样,在1月和3月表现不佳的股票会在9月和10月表现良好。同样地,这种可预测性也适用于从9月和10月到1月和3月的反向预测。

每年1月和3月,我们根据前2年至5年期间股票在情绪不一致月份(9月和10月)的平均历史回报率,将所有选定的股票分为十分位数。同样地,在每年的9月和10月,我们根据前2年至5年期间股票在情绪不一致月份(1月和3月)的平均历史回报率将股票进行排序。

多空投资组合做多(买入)情绪不一致月份表现最差的十分位股票,做空(卖出)表现最好的十分位股票。投资组合为等权重。

策略合理性

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

论文来源

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

<摘要>

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

回测表现

年化收益率6.05%
波动率6.25%
Beta-0.012
夏普比率0.97
索提诺比率-0.393
最大回撤N/A
胜率50%

完整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 CrossSectionalMoodReversalStrategyinEquities(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 = 10
        self.period:int = self.regression_year_window * 12
        self.noncongruent_period:int = 5
        self.historical_pred_high_ret:Dict[Symbol, RollingWindow] = {}
        self.historical_pred_low_ret:Dict[Symbol, RollingWindow] = {}
        
        self.low_mood_months:List[int] = [1, 3]
        self.high_mood_months:List[int] = [9, 10]

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

        self.leverage:int = 3
        self.fundamental_count:int = 500
        self.selection_flag:bool = False
        self.rebalance_flag:bool = False
        self.UniverseSettings.Resolution = Resolution.Daily
        self.AddUniverse(self.FundamentalSelectionFunction)
        self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
        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_pred_low_ret:
                del self.historical_pred_low_ret[security.Symbol]

            if security.Symbol in self.historical_pred_high_ret:
                del self.historical_pred_high_ret[security.Symbol]
            
        for security in changes.AddedSecurities:
            security.SetFeeModel(CustomFeeModel())
            security.SetLeverage(self.leverage)

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

        selected:List[Symbol] = [x.Symbol
            for x in sorted([x for x in fundamental if x.HasFundamentalData and x.Market == 'usa' 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]]
   
        is_high_month:bool = False
        is_low_month:bool = False

        history:DataFrame = self.History(selected, 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:]
            year_range:List[int] = list(range(self.Time.year - self.regression_year_window, self.Time.year + 1))

            if self.Time.month == self.high_mood_months[1] + 1:
                is_high_month = True
                x_var_months:List[int] = self.high_mood_months
                y_var_months:List[int] = self.low_mood_months
            elif self.Time.month == self.low_mood_months[1] + 1:
                is_low_month = True
                x_var_months:List[int] = self.low_mood_months
                y_var_months:List[int] = self.high_mood_months
                
            # regression X variable data
            x_prespecified_months:List[datetime.date] = []
            for year in [asset_returns[asset_returns.index.year == year].index for year in year_range]:
                for date in year:
                    if date.month in x_var_months:
                        x_prespecified_months.append(date.date())

            # regression Y variable data
            y_prespecified_months:List[datetime.date] = []
            for year in [asset_returns[asset_returns.index.year == year].index for year in year_range]:
                for date in year:
                    if date.month in y_var_months:
                        y_prespecified_months.append(date.date())

            # average two relevant months
            x:np.ndarray = asset_returns.loc[x_prespecified_months].rolling(2).mean().iloc[::2, :].iloc[1:].values.T
            y:np.ndarray = asset_returns.loc[y_prespecified_months].rolling(2).mean().iloc[::2, :].iloc[1:].values.T

            pred_ret:Dict[Symbol, float] = {}
            for i, asset in enumerate(list(asset_returns.columns)):
                asset_s:Symbol = self.Symbol(asset)

                if not (any(np.isnan(value) for value in x[i]) or any(np.isnan(value) for value in x[i])):
                    model = self.multiple_linear_regression(x[i][:-1], y[i][1:])
                    pred_ret_:float = model.predict(x[i][-1])[0]
                    
                    # store mood month predicted non-congruent return
                    if is_low_month:
                        if asset_s not in self.historical_pred_low_ret:
                            self.historical_pred_low_ret[asset_s] = RollingWindow[float](self.noncongruent_period)
                        self.historical_pred_low_ret[asset_s].Add(pred_ret_)
                        mood_month_storage:Dict[Symbol, RollingWindow] = self.historical_pred_high_ret

                    if is_high_month:
                        if asset_s not in self.historical_pred_high_ret:
                            self.historical_pred_high_ret[asset_s] = RollingWindow[float](self.noncongruent_period)
                        self.historical_pred_high_ret[asset_s].Add(pred_ret_)
                        mood_month_storage:Dict[Symbol, RollingWindow] = self.historical_pred_low_ret

                    # sort all selected number of stocks into deciles based on their average historical non-congruent mood month return during years t−2 through t−5
                    if asset_s in mood_month_storage and mood_month_storage[asset_s].IsReady:
                        avg_mood_return:float = np.mean(list(mood_month_storage[asset_s])[2:])
                        pred_ret[asset_s] = avg_mood_return

            # sort by mean predicted non-congruent return
            if len(pred_ret) >= self.quantile:
                sorted_by_ret:List[Symbol] = sorted(pred_ret, key=pred_ret.get)
                quantile:int = int(len(sorted_by_ret) / self.quantile)
                long:List[Symbol] = sorted_by_ret[-quantile:]
                short:List[Symbol] = sorted_by_ret[:quantile]

                # EW
                for i, portfolio in enumerate([long, short]):
                    for symbol in portfolio:
                        self.weight[symbol] = ((-1) ** i) / len(portfolio)

        return list(self.historical_pred_low_ret.keys()) if is_low_month else list(self.historical_pred_high_ret.keys())

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

        if not(self.Time.month in self.low_mood_months + self.high_mood_months):
            self.Liquidate()
        else:
            for price_symbol, weight in self.weight.items():
                if price_symbol in data and data[price_symbol]:
                    self.SetHoldings(price_symbol, weight)

    def Selection(self) -> None:
        if self.Time.month in [self.low_mood_months[1] + 1, self.high_mood_months[1] + 1]:
            self.weight.clear()
            self.selection_flag = True

        self.rebalance_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: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