
“该策略根据特质最低回报(IMIN)对美国股票进行排序。该策略做多IMIN最高的五分之一股票,做空IMIN最低的五分之一股票,并每月对等权重投资组合进行再平衡。”
资产类别: 股票 | 地区: 美国 | 周期: 每月 | 市场: 股票 | 关键词: 特质
I. 策略概要
该研究考察了来自CRSP数据库(纽约证券交易所、美国证券交易所、纳斯达克)的美国股票,这些股票每月至少有15个交易日。关键变量,特质最低回报(IMIN),是每只股票卡哈特模型中每月的最低残差。股票根据IMIN分为五等份。投资策略涉及做多IMIN最高的五分之一股票,做空IMIN最低的五分之一股票。投资组合采用等权重,每月重新平衡,以捕捉与IMIN相关的业绩差异。这种方法利用了IMIN对股票回报的预测能力。
II. 策略合理性
投资者表现出行为偏差,由于非理性预期,他们高估了过去价格上涨(彩票股票)的股票,而忽视了危险股票。对特质最低回报(IMIN)的反应不足归因于有限的注意力、结构性不确定性和套利限制。虽然所有三个因素最初看起来都很重要,但同时分析表明,只有信息不确定性和套利限制解释了反应不足。这表明要么存在不对称的彩票偏好,要么IMIN未能完全捕捉危险股票。该策略的表现主要由小盘股驱动,这表明在实际实施中需要谨慎,以减轻相关风险。
III. 来源论文
Hazard Stocks and Expected Returns [点击查看论文]
- R. Jared DeLisle, Michael Ferguson, Haimanot Kassa。犹他州立大学亨茨曼商学院,辛辛那提大学金融系房地产专业,迈阿密大学,西弗吉尼亚大学金融系
<摘要>
危险股票与彩票股票相反。我们用过去一个月最低每日特质回报“IMIN”来代表危险股票,并研究危险股票与预期回报之间的关系。关于彩票股票的文献表明投资者应该折价危险股票。异常地,我们发现IMIN与未来回报之间存在负相关关系。做多高IMIN股票和做空低IMIN股票的对冲投资组合每月产生-0.52%至-0.76%的alpha。在控制了众多公司特征和公司事件后,结果仍然稳健。危险股票异常现象主要由套利限制驱动,其次由公司层面信息不确定性驱动。通过Reg SHO试点计划,我们提供了因果证据,表明彩票股票和危险股票之间明显的不对称偏好是由于套利不对称(Stambaugh et al., 2015)。这表明不对称套利可能会产生看似不对称的偏好。

IV. 回测表现
| 年化回报 | 7.83% |
| 波动率 | 15.38% |
| β值 | -0.291 |
| 夏普比率 | 0.51 |
| 索提诺比率 | -0.245 |
| 最大回撤 | N/A |
| 胜率 | 52% |
V. 完整的 Python 代码
from AlgorithmImports import *
import statsmodels.api as sm
import numpy as np
from typing import List, Dict, Tuple
from numpy import isnan
from pandas.core.frame import dataframe
from pandas.core.series import Series
#endregion
class MinimumIdiosyncraticReturnsinStocks(QCAlgorithm):
def Initialize(self) -> None:
self.SetStartDate(2010, 1, 1)
self.SetCash(100000)
self.exchange_codes: List[str] = ['NYS', 'NAS', 'ASE']
# Daily price data.
self.period: int = 21
self.quantile: int = 5
self.leverage: int = 10
self.min_share_price: int = 5
self.momentum_period: int = 12
self.fundamental_count: int = 3000
self.fundamental_sorting_key = lambda x: x.MarketCap
self.long: List[Symbol] = []
self.short: List[Symbol] = []
self.data: Dict[Symbol, RollingWindow] = {}
self.market: Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
self.data[self.market] = RollingWindow[float](self.period)
# Factors.
self.size_factor_symbols: List[Tuple[Symbol, bool]] = [] # Symbol,long flag tuple.
self.value_factor_symbols: List[Tuple[Symbol, bool]] = []
self.momentum_factor_symbols: List[Tuple[Symbol, bool]] = []
self.selection_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.AfterMarketOpen(self.market), self.Selection)
def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
for security in changes.AddedSecurities:
security.SetLeverage(self.leverage)
security.SetFeeModel(CustomFeeModel())
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].Add(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.MarketCap != 0
and x.Price > self.min_share_price
and x.SecurityReference.ExchangeId in self.exchange_codes
and not isnan(x.ValuationRatios.PBRatio) and x.ValuationRatios.PBRatio != 0
]
if len(selected) > self.fundamental_count:
selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]
# Warmup price rolling windows.
for stock in selected:
symbol: Symbol = stock.Symbol
if symbol in self.data:
continue
self.data[symbol] = RollingWindow[float](self.momentum_period*self.period)
history: dataframe = self.History(symbol, self.period, Resolution.Daily)
if history.empty:
self.Log(f"Not enough data for {symbol} yet.")
continue
closes: Series = history.loc[symbol].close
for time, close in closes.items():
self.data[symbol].Add(close)
selected = [x for x in selected if self.data[x.Symbol].IsReady]
if len(selected) < self.quantile:
return Universe.Unchanged
market_factor_vector: np.ndarray = []
size_factor_vector: np.ndarray = []
value_factor_vector: np.ndarray = []
momentum_factor_vector: np.ndarray = []
# Market factor.
if self.market in self.data and self.data[self.market].IsReady:
daily_closes: np.ndarray = np.array([x for x in self.data[self.market]][:self.period])
market_factor_vector = daily_closes[:-1] / daily_closes[1:] - 1
# Size factor.
sorted_by_market_cap: List[Tuple[Fundamental, float]] = sorted(selected, key = lambda x: x.MarketCap, reverse = True)
quantile: int = int(len(sorted_by_market_cap) / self.quantile)
size_factor_long: List[Symbol] = [(i.Symbol, True) for i in sorted_by_market_cap[-quantile:]]
size_factor_short: List[Symbol] = [(i.Symbol, False) for i in sorted_by_market_cap[:quantile]]
# Calculate last month's performance.
if len(self.size_factor_symbols) != 0:
size_factor_vector = self.factor_daily_returns(self.data, self.size_factor_symbols)
# Store new factor symbols.
self.size_factor_symbols = size_factor_long + size_factor_short
# Value factor.
sorted_by_pb: List[Tuple[Fundamental, float]] = sorted(selected, key = lambda x: x.ValuationRatios.PBRatio, reverse=False)
quantile: int = int(len(sorted_by_pb) / self.quantile)
value_factor_long: List[Symbol] = [(i.Symbol, True) for i in sorted_by_pb[:quantile]]
value_factor_short: List[Symbol] = [(i.Symbol, False) for i in sorted_by_pb[-quantile:]]
# Calculate last month's performance.
if len(self.value_factor_symbols) != 0:
value_factor_vector = self.factor_daily_returns(self.data, self.value_factor_symbols)
# Store new factor symbols.
self.value_factor_symbols = value_factor_long + value_factor_short
# Momentum factor.
sorted_by_momentum: List[Tuple[Fundamental, float]] = sorted([x for x in selected if self.data[x.Symbol].Count >= self.momentum_period * self.period],
key = lambda x: self.Return([x for x in self.data[x.Symbol]][:self.momentum_period * self.period][self.period:]), reverse = True)
quantile: int = int(len(sorted_by_momentum) / self.quantile)
momentum_factor_long: List[Symbol] = [(i.Symbol, True) for i in sorted_by_momentum[:quantile]]
momentum_factor_short: List[Symbol] = [(i.Symbol, False) for i in sorted_by_momentum[-quantile:]]
# Calculate last month's performance.
if len(self.momentum_factor_symbols) != 0:
momentum_factor_vector = self.factor_daily_returns(self.data, self.momentum_factor_symbols)
# Store new factor symbols.
self.momentum_factor_symbols = momentum_factor_long + momentum_factor_short
IMIN: Dict[Symbol, float] = {}
long: List[Symbol] = []
short: List[Symbol] = []
# Every factor vector is ready.
if len(market_factor_vector) == self.period - 1 and \
len(size_factor_vector) == self.period - 1 and \
len(value_factor_vector) == self.period - 1 and \
len(momentum_factor_vector) == self.period - 1:
# Residual return calc.
x: List[List[float]] = [[x for x in market_factor_vector][::-1],
[x for x in size_factor_vector][::-1],
[x for x in value_factor_vector][::-1],
[x for x in momentum_factor_vector][::-1]]
for stock in selected:
symbol: Symbol = stock.Symbol
# 12 months of stock history is ready.
daily_prices: np.ndarray = np.array([x for x in self.data[symbol]][:self.period])
daily_returns: np.ndarray = daily_prices[:-1] / daily_prices[1:] - 1
regression_model: RegressionResultWrapper = MultipleLinearRegression(x, daily_returns[::-1])
IMIN[symbol] = min(regression_model.resid)
sorted_by_IMIN: List[Tuple[Symbol, float]] = sorted(IMIN.items(), key = lambda x: x[1], reverse = True)
quantile: int = int(len(sorted_by_IMIN) / self.quantile)
self.long: List[Symbol] = [x[0] for x in sorted_by_IMIN[:quantile]]
self.short: List[Symbol] = [x[0] for x in sorted_by_IMIN[-quantile:]]
return self.long + self.short
def OnData(self, data: Slice) -> None:
if not self.selection_flag:
return
self.selection_flag = False
# 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)
self.long.clear()
self.short.clear()
def Selection(self) -> None:
self.selection_flag = True
def factor_daily_returns(self, data: Dict[Symbol, RollingWindow], factor_symbols: List[Tuple[Symbol, bool]]) -> np.ndarray:
daily_returns: np.ndarray = np.array([float(0) for x in range(self.period - 1)])
if len(factor_symbols) != 0:
for symbol, long_flag in factor_symbols:
if symbol in data and data[symbol].Count >= self.period:
daily_closes = np.array([x for x in self.data[symbol]][:self.period])
if long_flag:
daily_returns += (daily_closes[:-1] / daily_closes[1:] - 1)
else:
daily_returns -= (daily_closes[:-1] / daily_closes[1:] - 1)
daily_returns /= len(factor_symbols)
return daily_returns
def Return(self, values: List[float]) -> float:
return (values[0] / values[-1]) - 1
def MultipleLinearRegression(x: np.ndarray, y: np.ndarray):
x = np.array(x).T
x = sm.add_constant(x)
result: RegressionResultWrapper = sm.OLS(endog=y, exog=x).fit()
return result
# 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"))