在股票中的动量效应应用违约风险过滤
登录后收藏学术论文
Momentum and Aggregate Default Risk
阿尔文德·马哈詹(Arvind Mahajan)
- ?阿列克斯·佩特科维奇(Alex Petkevich)和拉莉察·佩特科娃(Ralitsa Petkova)。德克萨斯农工大学 - 金融系,丹佛大学,凯斯西储大学 - 银行与金融系。
在本文中,我们通过整体经济违约风险的变化来解释动量收益。首先,我们发现动量收益仅在高违约冲击期间为正,而在其他时期则不存在。其次,我们提供证据表明,条件违约冲击因子在截面定价中起作用,并能解释大部分动量收益。根据我们的研究结果,在高违约冲击期间,赢家的潜在风险可能高于输家。我们在通常不观察到动量效应的不同子时期以及国际数据中证实了这一发现。此外,我们通过将动量收益与财务困境期间的潜在股东回收联系起来,提供了对此发现的解释。研究表明,在整体违约状况恶化的情况下,由于股东议价能力较低,赢家的相对风险较高。这些结果表明,动量收益包含与整体违约相关的系统性成分,并可在理性框架下得到解释。
策略概要
该策略针对AMEX、NYSE和NASDAQ股票,排除那些价格低于1美元的股票、外国股票和ADR(美国存托凭证)。根据Jegadeesh和Titman(1993)的动量方法,股票根据从t-6到t-1月份的累计回报排名,跳过一个月。每月形成动量投资组合,权重相等,并持有六个月。投资者使用穆迪CCC企业债券指数与10年期美国国债之间的利差来计算整体违约溢价。模型的残差估计意外违约冲击。动量投资组合仅在高违约冲击期间持有,通过每月的残差中位数来识别。
策略合理性
研究表明,当整体违约风险意外上升时,企业层面的违约风险变得更加重要,因为高信用风险股票在这些情况下更容易违约,从而导致其表现下降和观察到的动量效应。因此,动量策略的回报是随时间变化的,在高违约冲击时期动量效应更明显,而在低违约冲击时期则较弱。
回测表现
波动率22.28%
夏普比率0.98
索提诺比率-0.485
胜率47%
完整 Python 代码
from AlgorithmImports import *
import numpy as np
import statsmodels.api as sm
from typing import List, Dict
#endregion
class DefaultRiskFilterAppliedOnMomentumEffectWithinStocks(QCAlgorithm):
def Initialize(self) -> None:
self.SetStartDate(2010, 1, 1)
self.SetCash(100000)
self.exchange_codes:List[str] = ['NYS', 'NAS', 'ASE']
self.symbol: Symbol = self.AddEquity("SPY", Resolution.Daily).Symbol
self.data: Dict[Symbol, SymbolData] = {}
self.managed_queue: List[RebalanceQueueItem] = []
self.period: int = 6 * 21 # Storing prices for 6 months
self.holding_months: int = 6 # Holding 1/6 of whole portfolio for six months
self.month_period: int = 21
self.leverage: int = 5
self.quantile: int = 10
self.min_share_price: int = 1
self.regression_period: int = 21 * 3 # Three months of DEFt daily data
self.regression_data: RollingWindow = RollingWindow[float](self.regression_period)
self.DEFt: Symbol = self.AddData(QuantpediaBAMLH0A3HYC, 'BAMLH0A3HYC', Resolution.Daily).Symbol
self.default_shock: RollingWindow = RollingWindow[float](12 * 5) # Each month stores residual from regression for 5 years.
self.fundamental_count: int = 500
self.fundamental_sorting_key = lambda x: x.DollarVolume
self.stop_trading: bool = False
self.selection_flag: bool = False
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
self.settings.daily_precise_end_time = False
self.UniverseSettings.Resolution = Resolution.Daily
self.AddUniverse(self.FundamentalSelectionFunction)
self.Schedule.On(self.DateRules.MonthEnd(self.symbol), self.TimeRules.AfterMarketOpen(self.symbol), self.Selection)
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]:
# update the rolling window every day
for stock in fundamental:
symbol: Symbol = stock.Symbol
# store monthly price
if symbol in self.data:
self.data[symbol].update(stock.AdjustedPrice)
if not self.selection_flag:
return Universe.Unchanged
selected: List[Fundamental] = [
x for x in fundamental if x.HasFundamentalData and x.Market == 'usa' and x.Price > self.min_share_price and \
x.SecurityReference.ExchangeId in self.exchange_codes
]
if len(selected) > self.fundamental_count:
selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]
six_months_momentum = {}
# warmup price rolling windows
for stock in selected:
symbol: Symbol = stock.Symbol
if symbol not in self.data:
self.data[symbol] = SymbolData(self.period)
history = self.History(symbol, self.period, Resolution.Daily)
if history.empty:
self.Log(f"Not enough data for {symbol} yet")
continue
closes = history.loc[symbol].close
for time, close in closes.items():
self.data[symbol].update(close)
if not self.data[symbol].is_ready():
continue
six_months_closes: List[float] = [x for x in self.data[symbol]._closes][:self.month_period] # Skip last month according to strategy
six_months_return: float = (six_months_closes[0] - six_months_closes[-1]) / six_months_closes[-1]
six_months_momentum[symbol] = six_months_return
long: List[Symbol] = []
short: List[Symbol] = []
# continue only if we have enough stocks for decile selection
if len(six_months_momentum) >= self.quantile:
quantile: int = int(len(six_months_momentum) / self.quantile)
sorted_by_momentum: List[Symbol] = [x[0] for x in sorted(six_months_momentum.items(), key=lambda item: item[1])]
long = sorted_by_momentum[-quantile:] # top decile are winners
short = sorted_by_momentum[:quantile] # bottom decile are losers
long_w: float = self.Portfolio.TotalPortfolioValue / self.holding_months / len(long)
short_w: float = self.Portfolio.TotalPortfolioValue / self.holding_months / len(short)
# symbol/quantity collection
long_symbol_q: List[Tuple[Symbol, int]] = [(x, np.floor(long_w / self.data[x]._last_price)) for x in long]
short_symbol_q: List[Tuple[Symbol, int]] = [(x, -np.floor(short_w / self.data[x]._last_price)) for x in short]
self.managed_queue.append(RebalanceQueueItem(long_symbol_q + short_symbol_q))
return long + short
def OnData(self, data: Slice) -> None:
if self.DEFt in data:
if data[self.DEFt]:
price: float = data[self.DEFt].Value
if price != 0:
self.regression_data.Add(price)
# make sure DEFt data is still comming in
DEFt_last_update_date: Dict[Symbol, datetime.date] = QuantpediaBAMLH0A3HYC.get_last_update_date()
if self.Securities[self.DEFt].GetLastData() and self.Time.date() > DEFt_last_update_date[self.DEFt]:
self.stop_trading = True
if self.Portfolio.Invested:
self.Liquidate()
return
if not self.selection_flag:
return
self.selection_flag = False
current_residual: Union[None, float] = None
if self.regression_data.IsReady:
regression_data: List[float] = [x for x in self.regression_data]
Y: List[float] = regression_data[:self.month_period] # Current month data
X: List[float] = [
regression_data[self.month_period:self.month_period * 2], # Month before
regression_data[-self.month_period:] # Month which was 2 Months before
]
regression_model: RegressionResultWrapper = self.MultipleLinearRegression(X, Y)
# store last residual
current_residual = regression_model.resid[-1]
self.default_shock.Add(current_residual)
invest_flag: bool = False
if self.default_shock.IsReady and current_residual:
residuals_median: float = np.median([x for x in self.default_shock])
parts_to_remove: List[RebalanceQueueItem] = []
# go through each part of portfolio
for portfolio_part in self.managed_queue:
# liquidate stocks with 6 months holding
if portfolio_part.holding_time == 6:
for symbol, quantity in portfolio_part.symbol_q:
if self.Portfolio[symbol].Invested:
# liquidate long and short stocks in this part of portfolio
self.MarketOrder(symbol, -quantity)
parts_to_remove.append(portfolio_part)
# Increment holding time of 1/6 portfolio
portfolio_part.holding_time += 1
for part_to_remove in parts_to_remove:
# Remove portfolio part, because we held it for 6 months
self.managed_queue.remove(part_to_remove)
# Check if we invest or liquidate
if current_residual > residuals_median:
invest_flag = True
# Default shock data aren't ready, so we have to remove portofolio part, which was added.
elif len(self.managed_queue) > 0:
self.managed_queue.pop()
# Trade execution
if invest_flag:
# We either hold portfolio and invest into the new part of it
# Or invest into whole portfolio, because we liquidated it
if self.Portfolio.Invested:
# Get portfolio part, which was lastly added
portfolio_part = self.managed_queue[-1]
# We keep holding old parts of portfolio and we invest into the new one
open_symbol_q = []
for symbol, quantity in portfolio_part.symbol_q:
if symbol in data and data[symbol]:
self.MarketOrder(symbol, quantity)
open_symbol_q.append((symbol, quantity))
portfolio_part.symbol_q = open_symbol_q
else:
# We need to invest into whole portfolio, because we liquidated it
for portfolio_part in self.managed_queue:
# Go long and short stocks in this part of portofolio
open_symbol_q = []
for symbol, quantity in portfolio_part.symbol_q:
if symbol in data and data[symbol]:
self.MarketOrder(symbol, quantity)
open_symbol_q.append((symbol, quantity))
portfolio_part.symbol_q = open_symbol_q
else: # Signal is to Liquidate whole portfolio
self.Liquidate()
def MultipleLinearRegression(self, x, y):
x = np.array(x).T
x = sm.add_constant(x)
result: RegressionResultWrapper = sm.OLS(endog=y, exog=x).fit()
return result
def Selection(self) -> None:
if not self.stop_trading:
self.selection_flag = True
class RebalanceQueueItem():
def __init__(self, symbol_q: List[Symbol]) -> None:
self.symbol_q: List[Symbol] = symbol_q
self.holding_time: int = 0 # Holding in months
class SymbolData():
def __init__(self, period: int) -> None:
self._last_price: Union[None, float] = None
self._closes: RollingWindow = RollingWindow[float](period)
def update(self, close: float) -> None:
self._closes.Add(close)
self._last_price = close
def is_ready(self) -> bool:
return self._closes.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"))
# Quantpedia data.
# NOTE: IMPORTANT: Data order must be ascending (datewise)
class QuantpediaBAMLH0A3HYC(PythonData):
_last_update_date:Dict[Symbol, datetime.date] = {}
@staticmethod
def get_last_update_date() -> Dict[Symbol, datetime.date]:
return QuantpediaBAMLH0A3HYC._last_update_date
def GetSource(self, config: SubscriptionDataConfig, date: datetime, isLiveMode: bool) -> SubscriptionDataSource:
return SubscriptionDataSource("data.quantpedia.com/backtesting_data/index/{0}.csv".format(config.Symbol.Value), SubscriptionTransportMedium.RemoteFile, FileFormat.Csv)
def Reader(self, config: SubscriptionDataConfig, line: str, date: datetime, isLiveMode: bool) -> BaseData:
data = QuantpediaBAMLH0A3HYC()
data.Symbol = config.Symbol
if not line[0].isdigit(): return None
split: str = line.split(';')
if split[1] != '.':
data.Time = datetime.strptime(split[0], "%Y-%m-%d") + timedelta(days=1)
value = float(split[1])
data.Value = value
if config.Symbol not in QuantpediaBAMLH0A3HYC._last_update_date:
QuantpediaBAMLH0A3HYC._last_update_date[config.Symbol] = datetime(1,1,1).date()
if data.Time.date() > QuantpediaBAMLH0A3HYC._last_update_date[config.Symbol]:
QuantpediaBAMLH0A3HYC._last_update_date[config.Symbol] = data.Time.date()
return data