
“该策略通过根据空头兴趣意外排序来交易美国股票,做多低意外股票,做空高意外股票,使用价值加权投资组合,每月重新平衡。”
资产类别: 股票 | 地区: 美国 | 周期: 每月 | 市场: 股票 | 关键词: 空头
I. 策略概要
该策略重点关注AMEX、NYSE和NASDAQ股票,股票代码为10和11,排除股价低于5美元或低于纽约证券交易所市值5%分位数的股票。关键变量是空头兴趣比率(月中空头兴趣除以已发行股票)。空头兴趣意外计算为去均值的空头兴趣比率除以其12个月波动率。股票每月根据其空头兴趣意外排序为十个分位数。该策略做多最低十分位数(最低意外)和做空最高十分位数(最高意外)。投资组合采用价值加权,每月重新平衡以利用空头兴趣意外。
II. 策略合理性
该策略的功能源于识别卖空中的意外,而不是空头兴趣的绝对水平或变化。虽然空头兴趣比率因公司而异且持续存在,但它们本身无法表明知情卖空或错误定价。关键在于相对变化:偏离稳定的空头兴趣水平对于波动性较低的公司来说信息量更大。通过将其去均值的空头兴趣比率按其逆波动率进行缩放,该方法捕捉了变化和波动,有效地隔离了卖空意外。
利用这些意外的多空策略是稳健的,无法用传统的资产定价模型、其他空头兴趣策略或卖空限制来解释。它识别了市场错误定价——一个新颖的发现——由于非流动性或信息不确定性等交易障碍而持续存在。
III. 来源论文
Surprise in Short Interest [点击查看论文]
- Matthias X. Hanauer、Pavel Lesnevski 和 Esad Smajlbegovic。慕尼黑工业大学 (TUM);Robeco 机构资产管理公司。Union Investment Institutional GmbH。鹿特丹伊拉斯姆斯大学 (EUR) – 伊拉斯姆斯经济学院 (ESE);伊拉斯姆斯管理研究院 (ERIM);丁伯根研究所
<摘要>
我们通过解释空头兴趣数据中重要的横截面和分布差异,提取了卖空活动的新闻成分。由此产生的空头兴趣意外度量负向预测美国和国际股票回报的横截面。我们的结果还表明,这种可预测性源于卖空者对错误定价的知情交易以及投资者由于锚定过去空头兴趣而产生的反应不足。最后,与套利成本高昂的概念一致,回报可预测性在流动性差、波动性大的股票以及信息不确定性高的股票中更强,但重要的是,与卖空摩擦无关。


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"))