“该策略通过根据空头兴趣意外排序来交易美国股票,做多低意外股票,做空高意外股票,使用价值加权投资组合,每月重新平衡。”

I. 策略概要

该策略重点关注AMEX、NYSE和NASDAQ股票,股票代码为10和11,排除股价低于5美元或低于纽约证券交易所市值5%分位数的股票。关键变量是空头兴趣比率(月中空头兴趣除以已发行股票)。空头兴趣意外计算为去均值的空头兴趣比率除以其12个月波动率。股票每月根据其空头兴趣意外排序为十个分位数。该策略做多最低十分位数(最低意外)和做空最高十分位数(最高意外)。投资组合采用价值加权,每月重新平衡以利用空头兴趣意外。

II. 策略合理性

该策略的功能源于识别卖空中的意外,而不是空头兴趣的绝对水平或变化。虽然空头兴趣比率因公司而异且持续存在,但它们本身无法表明知情卖空或错误定价。关键在于相对变化:偏离稳定的空头兴趣水平对于波动性较低的公司来说信息量更大。通过将其去均值的空头兴趣比率按其逆波动率进行缩放,该方法捕捉了变化和波动,有效地隔离了卖空意外。

利用这些意外的多空策略是稳健的,无法用传统的资产定价模型、其他空头兴趣策略或卖空限制来解释。它识别了市场错误定价——一个新颖的发现——由于非流动性或信息不确定性等交易障碍而持续存在。

III. 来源论文

Surprise in Short Interest [点击查看论文]

<摘要>

我们通过解释空头兴趣数据中重要的横截面和分布差异,提取了卖空活动的新闻成分。由此产生的空头兴趣意外度量负向预测美国和国际股票回报的横截面。我们的结果还表明,这种可预测性源于卖空者对错误定价的知情交易以及投资者由于锚定过去空头兴趣而产生的反应不足。最后,与套利成本高昂的概念一致,回报可预测性在流动性差、波动性大的股票以及信息不确定性高的股票中更强,但重要的是,与卖空摩擦无关。

IV. 回测表现

年化回报4.24%
波动率7.24%
β值0.014
夏普比率0.59
索提诺比率-0.216
最大回撤N/A
胜率52%

V. 完整的 Python 代码

from AlgorithmImports import *
from io import StringIO
from typing import List, Dict
from pandas.core.frame import dataframe
from numpy import isnan
class SurpriseinShortInterest(QCAlgorithm):
    def Initialize(self) -> None:
        self.SetStartDate(2017, 1, 1)
        self.SetCash(100_000)
        self.period: int = 21
        self.std_period: int = 12
        self.leverage: int = 5
        self.quantile: int = 10
        
        market: int = self.AddEquity('SPY', Resolution.Daily).Symbol
        
        self.short_interest: Dict[Symbol, RollingWindow] = {}
        self.short_interest_ratio: Dict[Symbol, float] = {}
        self.short_interest_ratio_period: int = 12
        
        self.weight: Dict[Symbol, float] = {}
        # source: https://www.finra.org/finra-data/browse-catalog/equity-short-interest/data
        text: str = self.Download('data.quantpedia.com/backtesting_data/economic/short_volume.csv')
        self.short_volume_df: dataframe = pd.read_csv(StringIO(text), delimiter=';')
        self.short_volume_df['date'] = pd.to_datetime(self.short_volume_df['date']).dt.date
        self.short_volume_df.set_index('date', inplace=True)
        self.fundamental_count: int = 3_000
        self.fundamental_sorting_key = lambda x: x.MarketCap
        self.selection_flag: bool = False
        self.UniverseSettings.Resolution = Resolution.Daily
        self.AddUniverse(self.FundamentalSelectionFunction)
        self.settings.daily_precise_end_time = False
        self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
        self.Schedule.On(self.DateRules.MonthStart(market), self.TimeRules.AfterMarketOpen(market), 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]:
        # monthly rebalance
        if not self.selection_flag:
            return Universe.Unchanged
    
        # check last date on custom data
        if self.Time.date() > self.short_volume_df.index[-1] or self.Time.date() < self.short_volume_df.index[0]:
            self.Liquidate()
            return Universe.Unchanged
        for stock in fundamental:
            symbol: Symbol = stock.Symbol
            ticker: str = symbol.Value
            if symbol not in self.short_interest:
                continue
            if ticker in self.short_volume_df.columns:
                if isnan(self.short_volume_df[self.short_volume_df.index <= self.Time.date()][ticker][-1]):
                    continue
                self.short_interest[symbol].Add(self.short_volume_df[self.short_volume_df.index <= self.Time.date()][ticker][-1] / stock.CompanyProfile.SharesOutstanding)
        selected: List[Fundamental] = [
            x for x in fundamental 
            if x.HasFundamentalData 
            and x.Market == 'usa'
            and x.MarketCap != 0
            and not isnan(x.EarningReports.BasicAverageShares.ThreeMonths) and x.EarningReports.BasicAverageShares.ThreeMonths != 0 
            and not isnan(x.CompanyProfile.SharesOutstanding) and x.CompanyProfile.SharesOutstanding != 0
            and x.Symbol.Value in self.short_volume_df.columns
            # and x.Symbol.Value not in self.tickers_to_ignore
        ]
        if len(selected) > self.fundamental_count:
            selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]
        surprise: Dict[Fundamental, float] = {}
        for stock in selected:
            symbol: Symbol = stock.Symbol
            ticker: str = symbol.Value
            if symbol not in self.short_interest:
                # create RollingWindow for specific stock symbol
                self.short_interest[symbol] = RollingWindow[float](self.period)
            if not self.short_interest[symbol].IsReady:
                continue
            short_interest_values: List[float] = [x for x in self.short_interest[symbol]]
            mid_month_short_interest: float = short_interest_values[int(len(short_interest_values)/ 2)]
            short_interest_ratio: float = mid_month_short_interest / stock.EarningReports.BasicAverageShares.ThreeMonths
            
            # update monthly short interest ratio
            if symbol not in self.short_interest_ratio:
                self.short_interest_ratio[symbol] = RollingWindow[float](self.short_interest_ratio_period)
            self.short_interest_ratio[symbol].Add(short_interest_ratio)
            
            if self.short_interest_ratio[symbol].IsReady:
                short_interest_ratio_values: np.ndarray = np.array([x for x in self.short_interest_ratio[symbol]])
                si_mean: float = np.mean(short_interest_ratio_values)
                
                # Source paper: volatility of the ratio - In particular, we use the past twelve-month moving window standard deviation of the short interest ratio.
                si_volatility: float = np.std(short_interest_ratio_values) * np.sqrt(self.std_period)
                
                surprise[stock] = (short_interest_ratio_values[0] - si_mean) / si_volatility
                    
        if len(surprise) < self.quantile:
            return Universe.Unchanged
            
        # sorting by short interest surprise
        sorted_by_surprise: List[Tuple[Fundametal, float]] = sorted(surprise.items(), key = lambda x: x[1])
        quantile: int = int(len(sorted_by_surprise) / self.quantile)
        long: List[Fundamental] = [x[0] for x in sorted_by_surprise[:quantile]]
        short: List[Fundamental] = [x[0] for x in sorted_by_surprise[-quantile:]]
        
        # market cap weighting
        for i, portfolio in enumerate([long, short]):
            mc_sum: float = sum(list(map(lambda stock: stock.MarketCap, portfolio)))
            for stock in portfolio:
                self.weight[stock.Symbol] = ((-1)**i) * stock.MarketCap / mc_sum
        
        return [x[0] for x in self.weight.items()]
    def OnData(self, data: Slice) -> None:
        # monthly rebalance        
        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 Selection(self) -> None:
        self.selection_flag = True
# 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 的更多信息

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

继续阅读