“该策略根据特质最低回报(IMIN)对美国股票进行排序。该策略做多IMIN最高的五分之一股票,做空IMIN最低的五分之一股票,并每月对等权重投资组合进行再平衡。”

I. 策略概要

该研究考察了来自CRSP数据库(纽约证券交易所、美国证券交易所、纳斯达克)的美国股票,这些股票每月至少有15个交易日。关键变量,特质最低回报(IMIN),是每只股票卡哈特模型中每月的最低残差。股票根据IMIN分为五等份。投资策略涉及做多IMIN最高的五分之一股票,做空IMIN最低的五分之一股票。投资组合采用等权重,每月重新平衡,以捕捉与IMIN相关的业绩差异。这种方法利用了IMIN对股票回报的预测能力。

II. 策略合理性

投资者表现出行为偏差,由于非理性预期,他们高估了过去价格上涨(彩票股票)的股票,而忽视了危险股票。对特质最低回报(IMIN)的反应不足归因于有限的注意力、结构性不确定性和套利限制。虽然所有三个因素最初看起来都很重要,但同时分析表明,只有信息不确定性和套利限制解释了反应不足。这表明要么存在不对称的彩票偏好,要么IMIN未能完全捕捉危险股票。该策略的表现主要由小盘股驱动,这表明在实际实施中需要谨慎,以减轻相关风险。

III. 来源论文

Hazard Stocks and Expected Returns [点击查看论文]

<摘要>

危险股票与彩票股票相反。我们用过去一个月最低每日特质回报“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"))

发表评论

了解 Quant Buffet 的更多信息

立即订阅以继续阅读并访问完整档案。

继续阅读