The strategy buys low beta and shorts high beta CRSP stocks based on pre-ranking CAPM beta, reacting to macroeconomic announcements, and holds positions for two days after the announcement.

I. STRATEGY IN A NUTSHELL

The strategy trades CRSP stocks in the highest and lowest beta deciles (5-year pre-ranking CAPM beta) around macroeconomic announcements. On announcement days, it shorts high-beta stocks and buys low-beta stocks, holding positions for two days. Stock selection is adjusted for diversification, aiming to exploit market reactions using beta as a risk factor.

II. ECONOMIC RATIONALE

According to CAPM, announcement-day returns show a positive slope, which flips post-announcement: high-beta stocks decline while low-beta stocks rise. This reflects slower market processing of negative news versus positive, creating predictable post-announcement corrections that the strategy exploits.

III. SOURCE PAPER

Post Macroeconomic Announcement Reversal [Click to Open PDF]

Niu, Zilong; Zhang, Terry — Institute of Financial Studies, Southwestern University of Finance and Economics; Australian National University (ANU).

<Abstract>

We document that on days following bad macroeconomic news, the stock market continues to decline, and the security market line has a significantly negative slope. We find weak evidence of return continuation after good macroeconomic news. These findings indicate that the market underreacts to bad news on the announcement day. The underreaction is stronger when intermediary capital is scarce and among stocks with tighter short-selling constraints, consistent with the theory of limits to arbitrage. This asymmetry in the initial market reaction to news inflates the announcement premium. Using a longer window to measure announcement returns results in insignificant announcement premium.

IV. BACKTEST PERFORMANCE

Annualised Return7.76%
Volatility9.02%
Beta-0.036
Sharpe Ratio0.86
Sortino Ratio-0.119
Maximum DrawdownN/A
Win Rate52

V. FULL PYTHON CODE

from AlgorithmImports import *
import numpy as np
from pandas.tseries.offsets import BDay
from scipy import stats
from typing import List, Dict
from pandas.core.frame import dataframe
from pandas.core.series import Series
#endregion
class MacroeconomicAnnouncementBetaReversal(QCAlgorithm):
    def Initialize(self) -> None:
        self.SetStartDate(2000, 1, 1)
        self.SetCash(100000)
        
        self.market: Symbol = self.AddEquity("SPY", Resolution.Daily).Symbol
        self.period: int = 5 * 12
        self.daily_period: int = 21
        self.quantile: int = 10
        self.leverage: int = 5
        
        self.data: Dict[Symbol, SymbolData] = {}
        self.selected_symbols: List[Symbol] = []
        self.long: List[Symbol] = []
        self.short: List[Symbol] = []
        csv_string_file: str = self.Download('data.quantpedia.com/backtesting_data/economic/economic_announcements.csv')
        dates: List[str] = csv_string_file.split('\r\n')
        announcement_dates: List[datetime.date] = [datetime.strptime(x, "%Y-%m-%d") for x in dates]
        sort_dates: List[datetime.date] = [(x + BDay(1)).date() for x in announcement_dates]
        liquidation_dates: List[datetime.date] = [(x + BDay(2)).date() for x in announcement_dates]
        
        self.fundamental_count: int = 500
        self.fundamental_sorting_key = lambda x: x.DollarVolume
        self.selection_flag: bool = False
        self.rebalance_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.MonthEnd(self.market), self.TimeRules.At(0,0), self.Selection)
        self.Schedule.On(self.DateRules.On(sort_dates), self.TimeRules.At(0,0), self.Rebalance)
        self.Schedule.On(self.DateRules.On(liquidation_dates), self.TimeRules.At(0,0), self.Liquidation)
    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]:
        # monthly selection
        if not self.selection_flag:
            return Universe.Unchanged
        self.selection_flag = False
        
        # calculate monthly return
        for stock in fundamental:
            symbol = stock.Symbol
            
            # check if current stock have last month price
            if symbol in self.data and self.data[symbol].last_month_price:
                self.data[symbol].update_monthly_return(stock.AdjustedPrice)
        
        selected: List[Fundamental] = [x for x in fundamental if x.HasFundamentalData]
        if len(selected) > self.fundamental_count:
            selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]
        
        # Store monthly return for every stock selected this month.
        for stock in selected + [self.market]:
            if stock == self.market:
                symbol = stock
            else:
                symbol: Symbol = stock.Symbol
            if symbol in self.data:
                continue
            
            self.data[symbol] = SymbolData(self.period)
            history: dataframe = self.History([symbol], self.daily_period * self.period, Resolution.Daily)
            
            if history.empty:
                self.Log(f"Not enough data for {symbol} yet")
                continue
            
            closes: Series = history.loc[symbol].close
            closes_grouped: Series = closes.groupby(pd.Grouper(freq='M')).last()
            for close in closes_grouped:
                self.data[symbol].update_monthly_return(close)
        
        # get stocks, which have ready monthly returns
        self.selected_symbols = [x.Symbol for x in selected if self.data[x.Symbol].is_ready() and x.Symbol != self.market]        
        
        return self.selected_symbols
    
    def OnData(self, data: Slice) -> None:
        if not self.rebalance_flag:
            return
        self.rebalance_flag = False
        # there has to be at least one selected symbol and market returns has to be ready
        if len(self.selected_symbols) == 0 or not self.data[self.market].is_ready():
            return
        
        market_monthly_returns: List[float] = [x for x in self.data[self.market].monthly_returns]
        
        beta: Dict[Symbol, float] = {}
        for symbol in self.selected_symbols:
            stock_monthly_returns: List[float] = [x for x in self.data[symbol].monthly_returns]
            
            # Linear regression - X = market returns, Y = stock returns
            slope, intercept, r_value, p_value, std_err = stats.linregress(market_monthly_returns, stock_monthly_returns)
            beta[symbol] = slope
        
        # check if there are enough data for decile selection    
        if len(beta) < self.quantile:
            self.Liquidate()
            return
          
        quantile: int = int(len(beta) / self.quantile) 
        sorted_by_beta: List[Symbol] = [x[0] for x in sorted(beta.items(), key=lambda item: item[1])]
        # long the lowest beta decile
        self.long: List[Symbol] = sorted_by_beta[:quantile]
        # short the highest beta decile
        self.short: List[Symbol] = sorted_by_beta[-quantile:]
        
        # Trade execution
        targets: List[PortfolioTarget] = []
        for i, portfolio in enumerate([self.long, self.short]):
            for symbol in portfolio:
                if symbol in data and data[symbol]:
                    targets.append(PortfolioTarget(symbol, ((-1) ** i) / len(portfolio)))
        
        self.SetHoldings(targets, True)
    def Rebalance(self) -> None:
        self.rebalance_flag = True
    def Selection(self) -> None:
        self.selection_flag = True
        
    def Liquidation(self) -> None:
        self.Liquidate()
class SymbolData():
    def __init__(self, period: int) -> None:
        self.monthly_returns: RollingWindow = RollingWindow[float](period)
        self.last_month_price = 0
        
    def update_monthly_return(self, price: float) -> None:
        if self.last_month_price != 0:
            monthly_return: float = (price - self.last_month_price) / self.last_month_price
            self.monthly_returns.Add(monthly_return)
        self.last_month_price = price
        
    def is_ready(self) -> bool:
        return self.monthly_returns.IsReady
# 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"))

VI. Backtest Performance

Leave a Reply

Discover more from Quant Buffet

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

Continue reading