
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.
ASSET CLASS: stocks | REGION: United States | FREQUENCY:
Daily | MARKET: equities | KEYWORD: Macroeconomic, Announcement, Beta, Reversal
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 Return | 7.76% |
| Volatility | 9.02% |
| Beta | -0.036 |
| Sharpe Ratio | 0.86 |
| Sortino Ratio | -0.119 |
| Maximum Drawdown | N/A |
| Win Rate | 52 |
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