Quant Buffet放轻松,别过度思虑

封闭式基金均值回归交易

登录后收藏

学术论文

Exploiting Closed-End Fund Discounts: The Market May Be Much More Inefficient than You Thought

作者利用封闭式基金折价:市场可能比你想象的更加低效 [点击查看论文]

机构
  • Federal Deposit Insurance Corporation
  • ?OCC
  • ?(Federal Deposit Insurance Corporation)
  • Oklahoma State University
  • ?Oklahoma State University - Stillwater - Spears School of Business
  • NLRutgers Sexual and Reproductive Health and Rights
  • Rutgers, The State University of New Jersey
  • ?Rutgers University, Newark - School of Business - Department of Finance & Economics

策略概要

该策略专注于具有足够流动性的封闭式基金(CEFs)。一种简单的方法是将CEFs按上个月的折价分为五等分,做多折价最大的基金,做空溢价最大的基金,每月进行再平衡。更高级的版本采用回归模型(第6页,等式4a和4b),该模型结合了过去的折价和折价的月度变化,以预测下个月的业绩。投资者做多预测业绩最高的基金,做空预测业绩最低的基金,每月重新平衡投资组合,以根据预测指标优化回报。

II. 策略合理性

有许多研究论文解释了封闭式基金(CEF)折价/溢价的原因(投资者情绪效应、交易摩擦、代理成本和管理技能)。研究表明,封闭式基金领域存在多种套利限制;因此,系统且耐心的策略应该能够从中提取不相关的回报。

回测表现

波动率9.49%
夏普比率1.92
索提诺比率0.303
胜率50%

完整 Python 代码

from AlgorithmImports import *
#endregion
class ClosedEndFundMeanReversionTrading(QCAlgorithm):
def Initialize(self):
 self.SetStartDate(2000, 1, 1)
 self.SetCash(100000)
 
 self.symbol_count: int = 100     # due to QC limitation, maximum amount of 100 individual custom data series can be loaded
 self.quantile: int = 5
 self.leverage: int = 3
 self.CEF_data: Dict[Symbol, CEFData] = {}  # CEF NAV and price storage
 
 # load csv with CEF tickers
 # source: https://stockanalysis.com/list/closed-end-funds/
 csv_string_file: str = self.Download('data.quantpedia.com/backtesting_data/equity/CEFs/CEFs.csv')
 line: str = csv_string_file.split('\r\n')
 line_split: List[str] = line[0].split(';')
 
 for ticker in line_split[:self.symbol_count]:
     stock_symbol: Symbol = self.AddEquity(ticker, Resolution.Daily).Symbol
     # subscribe to QuantpediaCEF with csv name
     cef_symbol: Symbol = self.AddData(QuantpediaCEF, ticker, Resolution.Daily).Symbol
     # create object for each subscribed symbol
     self.CEF_data[stock_symbol] = CEFData(cef_symbol)
 
 self.recent_month: int = -1
 self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
 
def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
 for security in changes.AddedSecurities:
     security.SetFeeModel(CustomFeeModel())
     security.SetLeverage(self.leverage)
				
def OnData(self, slice: Slice) -> None:
 rebalance_flag: bool = False
 discount: Dict[Symbol, float] = {}
 last_update_date: Dict[str, datetime.date] = QuantpediaCEF.get_last_update_date()
 # update NAV values for each CEF
 for stock_symbol, CEF_data in self.CEF_data.items():
     cef_symbol: Symbol = CEF_data.get_CEF_symbol()
     if cef_symbol in slice and slice[cef_symbol]:
         # update CEF's NAV
         nav: float = slice[cef_symbol].Value
         CEF_data.update_NAV(nav)
     if stock_symbol in slice and slice[stock_symbol]:
         # update CEF stock price
         stock_price: float = slice[stock_symbol].Value
         CEF_data.update_price(stock_price)
         if self.recent_month != self.Time.month:
             rebalance_flag = True
         
         # calculate discount
         if rebalance_flag:
             if CEF_data.is_ready() and stock_symbol.Value in last_update_date and self.Time.date() < last_update_date[stock_symbol.Value]:
                 discount[stock_symbol] = CEF_data.discount()
 
 # rebalance monthly
 if not rebalance_flag:
     return
 self.recent_month = self.Time.month
         
 if len(discount) < self.quantile:
     self.Liquidate()
     return
 
 quintile: int = int(len(discount) / self.quantile)
 sorted_by_discount: List[Symbol] = sorted(discount, key=discount.get)
 
 # long funds with the biggest discounts and short funds with the biggest premium  
 long: List[Symbol] = sorted_by_discount[:quintile]
 short: List[Symbol] = sorted_by_discount[-quintile:]
 
 # trade execution
 invested: List[Symbol] = [x.Key for x in self.Portfolio if x.Value.Invested and x.Key not in long + short]
 for symbol in invested:
     self.Liquidate(symbol)
 
 for i, portfolio in enumerate([long, short]):
     for symbol in portfolio:
         if symbol in slice and slice[symbol]:
             self.SetHoldings(symbol, ((-1) ** i) / len(portfolio))
class CEFData():
def __init__(self, cef_symbol: Symbol) -> None:
 self._cef_symbol: Symbol = cef_symbol
 self._NAV: float = -1
 self._price: float = -1

def get_CEF_symbol(self) -> Symbol:
 return self._cef_symbol
def update_NAV(self, nav: float) -> None:
 self._NAV = nav
 
def update_price(self, price: float) -> None:
 self._price = price
 
def is_ready(self) -> bool:
 return self._NAV != -1 and self._price != -1
 
def discount(self) -> float:
 # Difference between log market price and log NAV, which is the price premium in relative terms. 
 # In this framework, discounts are negative premiums.
 return np.log(self._price) - np.log(self._NAV)
 
# Quantpedia data
# NOTE: IMPORTANT: Data order must be ascending (datewise)
# NOTE: IMPORTANT: Name of the csv file has to be upper case
class QuantpediaCEF(PythonData):
_last_update_date:Dict[str, datetime.date] = {}
@staticmethod
def get_last_update_date() -> Dict[str, datetime.date]:
return QuantpediaCEF._last_update_date
# Source: https://finance.yahoo.com/quote/XGDLX?p=XGDLX
def GetSource(self, config, date, isLiveMode):
 return SubscriptionDataSource("data.quantpedia.com/backtesting_data/equity/CEFs/X{0}X.csv".format(config.Symbol.Value), SubscriptionTransportMedium.RemoteFile, FileFormat.Csv)
def Reader(self, config, line, date, isLiveMode):
 data = QuantpediaCEF()
 data.Symbol = config.Symbol
 
 if not line[0].isdigit(): return None
 split = line.split(';')
 
 data.Time = datetime.strptime(split[0], "%d.%m.%Y") + timedelta(days=1)
 data.Value = float(split[1])
 # store last update date
 if config.Symbol.Value not in QuantpediaCEF._last_update_date:
     QuantpediaCEF._last_update_date[config.Symbol.Value] = datetime(1,1,1).date()
 if data.Time.date() > QuantpediaCEF._last_update_date[config.Symbol.Value]:
     QuantpediaCEF._last_update_date[config.Symbol.Value] = data.Time.date()
 return data
# Custom fee model
class CustomFeeModel(FeeModel):
def GetOrderFee(self, parameters):
 fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
 return OrderFee(CashAmount(fee, "USD"))