Quant BuffetRelax, Not Over Thinking

Estimating Hedge Funds’ Returns Out of Sample

Log in to collect

Academic paper

Hedge Funds With(out) Edge

AuthorsEric Wilson

Institute
  • CAWilfrid Laurier University
  • CAMcMaster University
  • ?McMaster University - Michael G. DeGroote School of Business
  • ?Wilfrid Laurier University - School of Business & Economics

Strategy in a nutshell

Investment universe: hedge funds (Bloomberg, HFR, Lipper/TASS, OptionMetrics, Fung & Hsieh factors, Kenneth French Library, FRED).

Method: run 3-year rolling regressions of hedge fund returns on Short VIX returns. Select funds where α > 0 (unexplained positive return) and β ≤ 0 (no excess exposure to Short VIX).

Portfolio: equal-weighted, annually rebalanced each December, holding only hedge funds with true “edge” beyond Short VIX replication.

Economic rationale

Hedge funds divide into two groups: (i) those with real edge—delivering high Sharpe ratios and positive skewness, and (ii) those replicable via Short VIX exposure—low Sharpe, negative skewness, high fees. Investors gain by allocating only to funds with α > 0, β ≤ 0, capturing superior returns and convex payoffs while avoiding overpriced, VIX-linked hedge funds. This strategy isolates genuine alpha and enhances portfolio efficiency.

Backtest performance

Annualised return8%
Volatility15.38%
Beta0.158
Sharpe ratio0.52
Win rate61%

Full Python code

from AlgorithmImports import *
import data_tools
from typing import List, Dict
from pandas.core.frame import DataFrame
import numpy as np
import statsmodels.api as sm
# endregion

class EstimatingHedgeFundsReturnsOutofSample(QCAlgorithm):

def Initialize(self):
self.SetStartDate(2011, 1, 1)
self.SetCash(100000)

self.leverage:int = 5

self.max_missing_days:int = 90
self.selection_month:int = 1
self.t_stats_value_threshold:int = 2

self.period:int = 36
self.month_period:int = 21 
self.quantile:int = 5

self.traded_portfolio_portion:Dict[Symbol, float] = {}
self.data:Dict[Symbol, SymbolData] = {}
self.fund_performance:Dict[str, RollingWindow] = {}

self.holdings_by_fund:Dict[str, FundHoldings] = {}
self.ticker_universe:set = set()  # every ticker stored in hedge fund holdings data

self.funds_tickers:Dict[str, Dict[datetime.date, list]] = {}

hedge_fund_file_content:str = self.Download('data.quantpedia.com/backtesting_data/equity/hedge_fund_holdings/hedge_funds_holdings.json')
hedge_funds_data:Dict = json.loads(hedge_fund_file_content)

for index, hedge_fund_data in enumerate(hedge_funds_data):
    hedge_fund_names:list[str] = list(hedge_fund_data.keys())
    hedge_fund_names.remove('date')
    date:datetime.date = datetime.strptime(hedge_fund_data['date'], '%d.%m.%Y').date()

    for hedge_fund_name in hedge_fund_names:
        if hedge_fund_name not in self.holdings_by_fund:
            self.holdings_by_fund[hedge_fund_name] = data_tools.FundHoldings(hedge_fund_name)

        holding_list:list[StockHolding] = []
        holdings:list[Dict] = hedge_fund_data[hedge_fund_name]['stocks']
        for holding in holdings:
            ticker:str = holding['ticker']
            number_of_shares:int = int(holding['#_of_shares'])
            weight:float = float(holding['weight'])

            self.ticker_universe.add(ticker)

            if ticker not in self.funds_tickers:
                # initialize dictionary for stock's ticker
                self.funds_tickers[ticker] = {}

            if date not in self.funds_tickers[ticker]:
                # initialize list, where will be all funds, which hold this stock in this date
                self.funds_tickers[ticker][date] = []
            
            # add fund with stock weight in that fund to list == tuple (hedge_fund_name, weight)
            self.funds_tickers[ticker][date].append((hedge_fund_name, weight))

            holding_list.append(data_tools.StockHolding(ticker, number_of_shares, weight))
        
        self.holdings_by_fund[hedge_fund_name].update_holdings_by_date(date, holding_list)

self.market:Symbol = self.AddEquity("SPY", Resolution.Daily).Symbol
self.vix:Symbol = self.AddEquity("SVXY", Resolution.Daily).Symbol

# universe selection
self.selection_flag:bool = False
self.rebalance_flag:bool = False
self.UniverseSettings.Resolution = Resolution.Daily
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
self.UniverseSettings.Resolution = Resolution.Daily
self.AddUniverse(self.CoarseSelectionFunction, self.FineSelectionFunction)
self.Schedule.On(self.DateRules.MonthStart(self.market), self.TimeRules.BeforeMarketClose(self.market), self.Selection)

def OnSecuritiesChanged(self, changes:SecurityChanges) -> None:
for security in changes.AddedSecurities:
    security.SetFeeModel(data_tools.CustomFeeModel())
    security.SetLeverage(self.leverage)

for security in changes.RemovedSecurities:
    if security.Symbol in self.data:
        self.data.pop(security.Symbol)

def CoarseSelectionFunction(self, coarse:List[CoarseFundamental]) -> List[Symbol]:
# monthly selection
if not self.selection_flag:
    return Universe.Unchanged
self.selection_flag = False

# update the price every month
for stock in coarse:
    symbol:Symbol = stock.Symbol
    ticker:str = symbol.Value

    if ticker in self.ticker_universe:
        if symbol in self.data:
            self.data[symbol].update_data(stock.AdjustedPrice)

if self.vix in self.data:
    if not self.Securities[self.vix].Price == 0:
        self.data[self.vix].update_data(self.Securities[self.vix].Price)

# selected = [x.Symbol for x in coarse if x.HasFundamentalData and x.Market == 'usa' and x.AdjustedPrice >= 1 and x.Symbol.Value in self.ticker_universe]
selected:List[Symbol] = [x.Symbol
    for x in sorted([x for x in coarse if x.HasFundamentalData and x.Market == 'usa' and x.AdjustedPrice >= 1 and x.Symbol.Value in self.ticker_universe],
        key = lambda x: x.DollarVolume, reverse = True)]

# warmup price rolling windows
for symbol in selected + [self.vix]:
    if symbol in self.data:
        continue

    if symbol.Value in self.ticker_universe or symbol == self.vix:
        self.data[symbol] = data_tools.SymbolData(self.period)
        history:DataFrame = self.History(symbol, self.period * self.month_period, Resolution.Daily)
        if history.empty:
            self.Log(f"Not enough data for {symbol} yet.")
            continue
        closes:pd.Series = history.loc[symbol].close.groupby(pd.Grouper(freq='M')).last()
        for time, close in closes.iteritems():
            self.data[symbol].update_data(close)

return [x for x in selected if self.data[x].is_ready()]

def FineSelectionFunction(self, fine:List[FineFundamental]) -> List[Symbol]:
fine = [x for x in fine if x.MarketCap != 0 and x.AssetClassification.MorningstarSectorCode != MorningstarSectorCode.FinancialServices and \
        (x.SecurityReference.ExchangeId == 'NYS') or (x.SecurityReference.ExchangeId == 'NAS') or (x.SecurityReference.ExchangeId == 'ASE')]

fine:Dict[Symbol, FineFundamental] = {x.Symbol.Value: x for x in fine}

if len(fine) != 0:
    performance_by_stock:Dict[str, float] = {ticker: self.data[fine[ticker].Symbol].get_last_return() for ticker in self.ticker_universe \
        if ticker in fine and self.data[fine[ticker].Symbol].is_ready()}

    for fund, fund_data in self.holdings_by_fund.items():
        last_date = fund_data.get_latest_date(self.Time.date())
        if last_date is None:
            continue

        fund_performance:float = sum([(x.weight / 100) * performance_by_stock[x.ticker] for x in fund_data.holdings_by_date[last_date] if x.ticker in fine])

        if fund not in self.fund_performance:
            self.fund_performance[fund] = RollingWindow[float](self.period)
        self.fund_performance[fund].Add(fund_performance)

if not self.rebalance_flag:
    return Universe.Unchanged
# self.rebalance_flag = False

if not self.data[self.vix].is_ready():
    return Universe.Unchanged

vix_returns:np.ndarray = np.array(self.data[self.vix].get_returns())
selected_funds:Dict[str, FundHoldings] = {}

# run regression on every fund
for fund, fund_data in self.holdings_by_fund.items():
    if fund not in self.fund_performance:
        continue
    if not self.fund_performance[fund].IsReady:
        continue

    x:np.ndarray = vix_returns
    y:np.ndarray = np.array(list(self.fund_performance[fund])[::-1])

    model = self.multiple_linear_regression(x, y)

    # check alpha and t stats values
    if model.params[0] > 0 and abs(model.tvalues[0]) >= self.t_stats_value_threshold and abs(model.tvalues[1]) >= 2:
        selected_funds[fund] = fund_data

# calculate portions on stocks
if len(selected_funds) != 0:
    for fund, fund_data in selected_funds.items():
        last_date = fund_data.get_latest_date(self.Time.date())

        if last_date is None or (self.Time.date() - last_date).days >= self.max_missing_days:
            continue
        for data in fund_data.holdings_by_date[last_date]:
            if data.ticker not in fine:
                continue
            portion:float = ((self.Portfolio.TotalPortfolioValue / len(selected_funds)) * (data.weight / 100))
            if data.ticker not in self.traded_portfolio_portion:
                self.traded_portfolio_portion[fine[data.ticker].Symbol] = 0
            self.traded_portfolio_portion[fine[data.ticker].Symbol] += portion
            
return list(self.traded_portfolio_portion.keys())

def OnData(self, data: Slice):
if not self.rebalance_flag:
    return
self.rebalance_flag = False

# trade execution
stocks_invested:List[Symbol] = [x.Key for x in self.Portfolio if x.Value.Invested]
for symbol in stocks_invested:
    if symbol not in self.traded_portfolio_portion:
        self.Liquidate(symbol)

for symbol, portion in self.traded_portfolio_portion.items():
    if symbol in data and data[symbol]:
        quantity:float = (portion // data[symbol].Price) - self.Portfolio[symbol].Quantity
        self.MarketOrder(symbol, quantity)

self.traded_portfolio_portion.clear()

def Selection(self):
if self.Time.month != self.selection_month:
    self.selection_flag = True
    return
self.rebalance_flag = True
self.selection_flag = True

def multiple_linear_regression(self, x:np.ndarray, y:np.ndarray):
x = sm.add_constant(x, has_constant='add')
result = sm.OLS(endog=y, exog=x).fit()
return result