Quant Buffet放轻松,别过度思虑

ETF的创建/赎回活动与收益可预测性

登录后收藏

学术论文

ETF Arbitrage and Return Predictability

作者ETF Arbitrage, Non-Fundamental Demand, and Return Predictability [点击查看论文]

机构
  • University of Arizona
  • ?University of Arizona - Department of Finance
  • University of Colorado Boulder
  • ?University of Colorado at Boulder - Leeds School of Business
  • University of Utah
  • ?University of Utah - Department of Finance

策略概要

该策略针对超过1200只美国ETF,选择资产至少为5000万美元且上个月至少一半交易日显示股票创建或赎回活动的ETF。ETF根据买卖价差分为五个投资组合,买卖价差计算为平均卖出价减去买入价除以中点价。在每个买卖价差投资组合中,ETF根据股票创建/赎回活动进一步分为五分位数,形成25个投资组合。投资者做多股票变化量最低(赎回活动最高)和买卖价差最低的投资组合,做空股票变化量最高(创建活动最高)和买卖价差最低的投资组合。等权重投资组合每月重新平衡。

II. 策略合理性

随着投资者涌入,ETF价格上涨,但这种上涨通常在六个月内逆转。投资者倾向于将价格推高至基本价值之上,导致ETF表现逊于其标的指数。研究表明,ETF投资者的市场择时能力较差,他们在价格相对于标的资产被高估时买入,在价格被低估时卖出。回报可预测性分析显示,较大的ETF基金规模预示着后续表现较弱,而较小的基金规模预示着表现较好。这种系统性的错误定价凸显了投资者行为的低效率,这是由于择时能力差以及与基本资产价值不一致所致。

回测表现

波动率19.84%
夏普比率0.6
索提诺比率0.122
胜率58%

完整 Python 代码

from AlgorithmImports import *
from dateutil.relativedelta import relativedelta
#endregion
class ETFCreationRedemptionActivityAndReturnPredictability(QCAlgorithm):
def Initialize(self) -> None:
self.SetStartDate(2006, 1, 1) # etf data starts since 2006
self.SetCash(100_000)

self.spread_period: int = 21 # need n bid ask spread values for each ETF
self.leverage: int = 5
self.quantile: int = 5

self.data: Dict[Symbol, SymbolData] = {}
self.shares_outstanding: Dict[str, float] = {} 

self.long: List[Symbol] = []
self.short: List[Symbol] = []

self.last_custom_data_date: datetime.date = datetime(1, 1, 1).date()
market: Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol

csv_string_file: str = self.Download('data.quantpedia.com/backtesting_data/equity/etf_shares_outstanding.csv')
lines: List[str] = csv_string_file.split('\r\n')

# retrieve tickers from csv header
self.etf_tickers: List[str] = lines[0].split(';')[1:]

for etf_ticker in self.etf_tickers:
    self.shares_outstanding[etf_ticker] = {}

for line in lines[1:]: # iterate through each line except header
    if line == '':
        continue
    
    split: List[str] = line.split(';')
    date: datetime.date = datetime.strptime(split[0], "%Y-%m-%d").date()
    shares_outstanding_values: float = split[1:]
    total_shares_outstanding_values: int = len(shares_outstanding_values)
    
    if date > self.last_custom_data_date:
        self.last_custom_data_date = date
    # load share outstanding data and index them by year, month then day
    for index in range(1, total_shares_outstanding_values, 1):
        # get share outstanding value for specific ticker
        shares_outstanding_value: float = float(shares_outstanding_values[index])
        
        # make sure stored value won't be zero
        if shares_outstanding_value == 0:
            continue
        
        # get etf ticker, which belongs to current share outstanding value
        etf_ticker: str = self.etf_tickers[index]
        
        if date not in self.shares_outstanding[etf_ticker]:
            self.shares_outstanding[etf_ticker][date] = shares_outstanding_value

self.selection_flag: bool = False
self.UniverseSettings.Leverage = self.leverage
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.MonthEnd(market), self.TimeRules.BeforeMarketClose(market, 0), self.Selection)
def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
for security in changes.AddedSecurities:
    security.SetFeeModel(CustomFeeModel())
def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
# rebalance monthly    
if not self.selection_flag:
    return Universe.Unchanged

# filter ETFs universe
selected: List[Fundamental] = [x for x in fundamental if x.Symbol.Value in self.etf_tickers]

share_spread: Dict[Symbol, float] = {}
bid_ask_avg_spread: Dict[Symbol, float] = {}

for etf in selected:
    etf_symbol: Symbol = etf.Symbol
    
    # initialize SymbolData object for new etf symbol in data
    if etf_symbol not in self.data:
        self.data[etf_symbol] = SymbolData(self.spread_period)
    
    # make sure, data are ready    
    if not self.data[etf_symbol].are_data_ready():
        continue
    
    share_spread_value: float = self.GetShareSpread(etf_symbol.Value)
    
    # make sure there is valid share spread value
    if share_spread_value == None:
        continue
    
    bid_ask_avg_spread_value: float = self.data[etf_symbol].bid_ask_avg_spread()
    
    # store spreads
    share_spread[etf_symbol] = share_spread_value
    bid_ask_avg_spread[etf_symbol] = bid_ask_avg_spread_value
    
# there has to be enough ETFs for quintile selections   
if len(share_spread) < self.quantile or len(bid_ask_avg_spread) < self.quantile:
    return list(self.data.keys())
    
# quintile selections
quintile: int = int(len(share_spread) / self.quantile)
sorted_by_share_spread: List[Symbol] = [x[0] for x in sorted(share_spread.items(), key=lambda item: item[1])]
sorted_by_bid_ask_avg_spread: List[Symbol] = [x[0] for x in sorted(bid_ask_avg_spread.items(), key=lambda item: item[1])]

highest_share_spread: List[Symbol] = sorted_by_share_spread[-quintile:]
lowest_share_spread: List[Symbol] = sorted_by_share_spread[:quintile]

# highest_bid_ask_avg_spread = sorted_by_bid_ask_avg_spread[-quintile:]
lowest_bid_ask_avg_spread: List[Symbol] = sorted_by_bid_ask_avg_spread[:quintile]

# Investor goes long portfolio with the lowest ShareChange (quintile with the highest redemption activity) and simultaneously with the lowest bid-ask spread.
self.long = [x for x in lowest_share_spread if x in lowest_bid_ask_avg_spread]
# Investor goes short portfolio with the highest ShareChange (quintile with the highest creation activity) and simultaneously with the lowest bid-ask spread.
self.short = [x for x in highest_share_spread if x in lowest_bid_ask_avg_spread]

return list(self.data.keys())
def OnData(self, slice: Slice) -> None:
if self.time.date() > self.last_custom_data_date:
    self.liquidate()
    return
    
for etf_symbol in self.data:
    if slice.contains_key(etf_symbol) and slice[etf_symbol]:
        bid: float = self.Securities[etf_symbol].BidPrice
        ask: float = self.Securities[etf_symbol].AskPrice
        
        self.data[etf_symbol].update_bid_ask_spread(bid, ask)

# rebalance monthly
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 slice.contains_key(symbol) and slice[symbol]:
            targets.append(PortfolioTarget(symbol, ((-1) ** i) / len(portfolio)))

self.SetHoldings(targets, True)
self.long.clear()
self.short.clear()

def GetShareSpread(self, etf_ticker: str) -> float:
current_date: datetime.date = self.Time.date()
prev_month_date: datetime.date = current_date - relativedelta(months=1)

# get indexed ETF's share oustanding from dictionary
etf_shares_outstanding: float = self.shares_outstanding[etf_ticker]

# make sure share outstanding values are ready for each day
if current_date not in etf_shares_outstanding or prev_month_date not in etf_shares_outstanding:
    return None
    
# get current share oustanding value
curr_shares_outstanding: float = etf_shares_outstanding[current_date]

# get prev month share outstanding value
prev_shares_outstanding: float = etf_shares_outstanding[prev_month_date]

# return share spread
return (curr_shares_outstanding / prev_shares_outstanding) - 1

def Selection(self) -> None:
self.selection_flag = True

class SymbolData():
def __init__(self, spread_period: int) -> None:
self.bid_ask_spread: RollingWindow = RollingWindow[float](spread_period)

def update_bid_ask_spread(self, bid: float, ask: float) -> None:
midpoint: float = (bid + ask) / 2
spread: float = (ask - bid) / midpoint
self.bid_ask_spread.Add(spread)

def are_data_ready(self) -> bool:
return self.bid_ask_spread.IsReady

def bid_ask_avg_spread(self) -> float:
bid_ask_spreads: List[float] = [x for x in self.bid_ask_spread]
bid_ask_avg_spread: float = sum(bid_ask_spreads) / len(bid_ask_spreads)
return bid_ask_avg_spread

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