Quant Buffet放轻松,别过度思虑

在股票中的动量效应应用违约风险过滤

登录后收藏

学术论文

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