该策略根据特质性非对称性(IE)对美国股票进行排序,对低IE的股票进行多头交易,对高IE的股票进行空头交易。投资组合按价值加权配置,并每月进行再平衡。

I. 策略概述

该策略以CRSP数据库(NYSE、AMEX、NASDAQ)中股价高于5美元的美国股票为目标。利用过去12个月的数据,将每只股票的超额收益回归于市场超额收益和市场超额收益的平方。特质性非对称性(IE)定义为大幅收益和大幅亏损的累计概率差异,这些概率以均值为中心、标准差为界限计算得出。根据IE值将股票分为十分位,对低IE(风险更高)的股票做多,对高IE(风险较低)的股票做空。投资组合按价值加权配置,并每月进行再平衡,以捕捉非对称性趋势。

II. 策略合理性

该策略利用投资者的行为偏差,即投资者更倾向于选择具有更高极端收益可能性的股票(正IE),而避免可能产生严重亏损的股票。这种偏好导致高IE股票的价格被高估,低IE股票的价格被低估,从而使高IE股票的预期回报更低。与偏度(skewness)不同,新的非对称性测度捕捉了更高阶的非对称性,从而更有效地解释股票的横截面回报。研究表明,即使偏度为零,高上行非对称性仍与低回报强相关。基于十分位的分析验证了偏度本身无法可靠预测回报,而新的非对称性测度提供了关键的股票表现洞察。

III. 论文来源

Stock Return Asymmetry: Beyond Skewness [点击浏览原文]

<摘要>

本文提出了两种用于股票回报的非对称性测度。与传统的偏度测度不同,我们的测度基于数据的分布函数,而不仅仅是第三中心矩。通过实证研究表明,上行非对称性越大,回报越低,且这种关系在偏度为零时依然显著。我们的研究为股票表现提供了新的视角,同时验证了更高阶非对称性的重要性。

IV. 回测表现

年化收益率2.25%
波动率6.03%
Beta0.005
夏普比率0.37
索提诺比率-0.149
最大回撤N/A
胜率53%

V. 完整python代码

import statsmodels.api as sm
from AlgorithmImports import *
class IdiosyncraticAsymmetryInUSStocks(QCAlgorithm):
    def Initialize(self) -> None:
        self.SetStartDate(2000, 1, 1)
        self.SetCash(100000)
        
        self.data:Dict[Symbol, SymbolData] = {}
        self.weight:Dict[Symbol, float] = {}
        
        self.regression_period:int = 12 * 21 + 1    # need n daily prices
        self.selection_size:int = 10                # 10 = decile selection, 5 = quintile selection, ...
        self.leverage:int = 5
        self.min_share_price:float = 5.
        self.exchange_codes:List[str] = ['NYS', 'NAS', 'ASE']
        self.symbol:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
        
        # warm up SPY prices    
        self.PerformHistory(self.symbol)
        
        self.fundamental_count:int = 3000
        self.fundamental_sorting_key = lambda x: x.MarketCap
        
        self.selection_flag:bool = False
        self.UniverseSettings.Resolution = Resolution.Daily
        self.AddUniverse(self.FundamentalSelectionFunction)
        self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
        self.Schedule.On(self.DateRules.MonthStart(self.symbol), self.TimeRules.BeforeMarketClose(self.symbol, 0), self.Selection)
        self.settings.daily_precise_end_time = False
    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 daily closes
        for stock in fundamental:
            symbol = stock.Symbol
            
            if symbol in self.data:
                # update daily closes
                self.data[symbol].update_closes(stock.AdjustedPrice)
        
        # monthly rebalance
        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 and x.MarketCap != 0 and x.Symbol != self.symbol and x.CompanyReference.BusinessCountryID == 'USA' and \
            not np.isnan(x.FinancialStatements.IncomeStatement.ResearchAndDevelopment.TwelveMonths) and x.FinancialStatements.IncomeStatement.ResearchAndDevelopment.TwelveMonths != 0
        ]
        if len(selected) > self.fundamental_count:
            selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]
        # can't perform selection, when market closes aren't ready
        if not self.data[self.symbol].are_closes_ready():
            return Universe.Unchanged
            
        regression_x:List = [
            self.data[self.symbol].daily_returns(),
            self.data[self.symbol].daily_returns_squared()
        ]
        
        IE:Dict[Symbol, float] = {} # storing stocks IE values keyed by stocks symbols
        
        for stock in selected:
            symbol:Symbol = stock.Symbol
            
            # warm up stock prices
            if symbol not in self.data:
                self.PerformHistory(symbol)
            
            # check if closes data are ready
            if not self.data[symbol].are_closes_ready():
                continue
            
            # perform regression
            regression_y = self.data[symbol].daily_returns()
            
            regression_model = self.MultipleLinearRegression(regression_x, regression_y)
            
            # retrieve all residuals from regression
            daily_residuals = regression_model.resid
            
            # calcualte IE value from stock daily residuals
            IE_value:float = self.data[symbol].calculate_IE(daily_residuals)
            
            # store stock's IE value keyed by stock's symbol
            IE[stock] = IE_value
            
        # make sure, there are enough stocks for selection
        if len(IE) < (self.selection_size * 2):
            return Universe.Unchanged
        
        # perform selection
        selection_num:int = int(len(IE) / self.selection_size)
        sorted_by_IE:List[Fundamental] = [x[0] for x in sorted(IE.items(), key=lambda item: item[1])]
        # long stocks with low IE values
        long:List[Fundamental] = sorted_by_IE[:selection_num]
        
        # short stocks with high IE values
        short:List[Fundamental] = sorted_by_IE[-selection_num:]
        
        # calculate weights
        for i, portfolio in enumerate([long, short]):
            mc_sum:float = sum(map(lambda x: x.MarketCap, portfolio))
            for stock in portfolio:
                self.weight[stock.Symbol] = ((-1) ** i) * stock.MarketCap / mc_sum
        
        return list(self.weight.keys())
    def OnData(self, data: Slice) -> None:
        if not self.selection_flag:
            return
        self.selection_flag = False
        
        # trade execution
        portfolio:List[PortfolioTarget] = [PortfolioTarget(symbol, w) for symbol, w in self.weight.items() if symbol in data and data[symbol]]
        self.SetHoldings(portfolio, True)
        self.weight.clear()
        
    def PerformHistory(self, symbol):
        ''' warm up stock prices from History object based on symbol parameter '''
        
        self.data[symbol] = SymbolData(self.regression_period)
        history = self.History(symbol, self.regression_period, Resolution.Daily)
        
        # make sure history isn't empty
        if history.empty:
            return
        
        closes = history.loc[symbol].close
        for time, close in closes.items():
            self.data[symbol].update_closes(close)
            
    def MultipleLinearRegression(self, x, y):
        ''' perform multiple regression and return regression model '''
        
        x = np.array(x).T
        x = sm.add_constant(x)
        result = sm.OLS(endog=y, exog=x).fit()
        return result
        
    def Selection(self) -> None:
        self.selection_flag = True
        
class SymbolData():
    def __init__(self, regression_period: int):
        self._closes:RollingWindow = RollingWindow[float](regression_period)
        
    def update_closes(self, close: float) -> None:
        self._closes.Add(close)
        
    def are_closes_ready(self) -> bool:
        return self._closes.IsReady
        
    def daily_returns(self) -> np.ndarray:
        # calculate daily returns for period t-6 to t-1 months
        closes:np.ndarray = np.array([x for x in self._closes])[21:]
        daily_returns:np.ndarray = (closes[:-1] - closes[1:]) / closes[1:]
        return daily_returns
        
    def daily_returns_squared(self) -> List[float]:
        # calculate daily returns for period t-6 to t-1 months
        closes:np.ndarray = np.array([x for x in self._closes])[21:]
        daily_returns:np.ndarray = (closes[:-1] - closes[1:]) / closes[1:]
        daily_returns_squared:List[float] = [x*x for x in daily_returns]
        return daily_returns_squared
        
    def calculate_IE(self, residuals) -> int:
        average_residuals:float = np.average(residuals)
        two_residuals_std:float = 2 * np.std(residuals)
        
        avg_plus_two_std:float = average_residuals + two_residuals_std
        avg_minus_two_std:float = average_residuals - two_residuals_std
        
        over_avg_plus_two_std:int = 0    # counting number of residuals, which were over avg_plus_two_std
        under_avg_minus_two_std:int = 0  # counting number of residuals, which were under avg_minus_two_std
        
        for residual in residuals:
            if residual > avg_plus_two_std:
                over_avg_plus_two_std += 1
            elif residual < avg_minus_two_std:
                under_avg_minus_two_std += 1
                
        IE_value:int = over_avg_plus_two_std - under_avg_minus_two_std
        
        return IE_value
        
# Custom fee model
class CustomFeeModel(FeeModel):
    def GetOrderFee(self, parameters):
        fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
        return OrderFee(CashAmount(fee, "USD"))



发表评论

了解 Quant Buffet 的更多信息

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

继续阅读