Closed-End Fund Mean Reversion Trading
Log in to collectAcademic paper
Exploiting Closed-End Fund Discounts: The Market May Be Much More Inefficient than You Thought
Dilip K. Patro; Louis R. Piccotti; Yangru Wu
- 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"))