Quant BuffetRelax, Not Over Thinking

Cross-Sectional Mood Beta Strategy in Equities

Log in to collect

Academic paper

Mood Beta and Seasonalities in Stock Returns

AuthorsDavid Hirshleifer; Danling Jiang; Yuting Meng

Institute
  • National Bureau of Economic Research
  • ?Marshall School of Business, USC
  • ?National Bureau of Economic Research (NBER)
  • Stony Brook University
  • ?College of Business, Stony Brook University
  • University of South Florida St. Petersburg
  • ?University of South Florida - Department of Finance

Strategy in a nutshell

Using NYSE/AMEX/NASDAQ stocks, mood betas are estimated from returns in prespecified and realized high/low mood months via rolling regressions. Stocks are sorted into deciles, and long–short portfolios trade the extremes: long high mood-beta and short low mood-beta stocks in high-mood months (January, March), with positions reversed in low-mood months (September, October). Portfolios are rebalanced monthly, four times per year.

Economic rationale

Investor mood seasonality influences both aggregate and cross-sectional returns. Seasonal mispricing arises from mood-driven shifts in factor premia, rather than rational risk premia. Trading on mood beta exploits these predictable seasonal variations in investor sentiment

Backtest performance

Annualised return11.42%
Volatility15.71%
Beta0
Sharpe ratio0.73
Win rate49%

Full Python code

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