Quant BuffetRelax, Not Over Thinking

Closed-End Fund Mean Reversion Trading

Log in to collect

Academic paper

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

AuthorsDilip K. Patro; Louis R. Piccotti; Yangru Wu

Institute
  • 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

Strategy in a nutshell

This strategy targets liquid closed-end funds (CEFs). A simple version goes long on funds with the largest discounts and short on those with the largest premiums, rebalancing monthly. An advanced approach uses a regression model to predict next-month performance based on past discounts and changes, optimizing long and short positions monthly.

Economic rationale

CEF discounts and premiums arise from investor sentiment, trading frictions, agency costs, and managerial skill. Limits to arbitrage in the CEF market allow a systematic, patient strategy to capture uncorrelated returns.

Backtest performance

Annualised return18.2%
Volatility9.49%
Beta0.316
Sharpe ratio1.92
Sortino ratio0.303
Win rate50%

Full Python code

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