该策略基于Short VIX回报率,通过三年滚动回归估算参数,选择符合条件的对冲基金。投资规则要求α > 0 且β ≤ 0,对冲基金回报无法通过Short VIX策略完全解释。最终的均权投资组合每年12月再平衡,包含满足该规则的对冲基金。

策略概述

投资范围由各种数据库中的对冲基金组成(见下文数据来源,例如Lipper数据库)。本文中用于复制策略的数据来源包括:i) Bloomberg,ii) Hedge Fund Research (HFR) 数据库,iii) Lipper Hedge Fund Commercial Database (TASS),iv) OptionMetrics,v) Fung 和 Hsieh (2001) 趋势跟踪因子,vi) Kenneth French的在线数据库,vii) 圣路易斯联邦储备银行 (FRED) 数据库。

所选择的策略变体基于通过Short VIX回报率估算的参数。通过三年滚动回归估算参数,这是投资规则的基础。拟议的投资规则用于复制在市场中有优势的成功对冲基金:满足投资规则(即拒绝原假设),ˆα [alpha] > 0 且 ˆβ [beta] ≤ 0,最终的投资组合由满足该规则的对冲基金组成。Short VIX策略可以更好地解释并预测对冲基金回报,只要这种额外的变化能对对冲基金表现具有增量解释力。我们执行以下线性回归(线性单因素模型),以此来验证原假设 (H0)(方程(7)):HF Returns_t = α + β * Short VIX Returns_t + ε_t, H0 : α ≤ 0 且 β > 0,其中t代表每月频率,α代表由Short VIX回报未能解释的回报变化,β代表对冲基金对Short VIX策略的负荷。(原假设应解释为:对冲基金的回报可以通过对Short VIX策略基准的正敞口完全解释。)

基于通过Short VIX回报估算的参数,并在每年12月进行再平衡,最终投资组合是均权的,由所选对冲基金组成。

策略合理性

这项研究强调了对冲基金文献中一个较少研究的领域,即那些提供高夏普比率和正偏度的对冲基金,并提供证据表明,可以将对冲基金事先分为两类:一类具有优势,能够通过提供相对较高的夏普比率和正偏度为投资者创造价值;另一类没有优势,夏普比率较低且偏度为负。对于后者,投资者可以通过做空VIX期货合约来获得类似对冲基金的敞口。此外,这些投资者可能会获得更高的夏普比率,因为他们不再需要支付与对冲基金相关的较高费用。习惯于期待负偏度高夏普比率的投资者应更期待对冲基金能够为他们的投资组合提供高夏普比率和正偏度。未来的工作需要解释为何对冲基金投资者愿意投资负偏度的基金,而他们实际上可以投资提供更高夏普比率和凸性收益的对冲基金。探讨这一问题可能有助于更深入地理解对冲基金及投资者行为。

论文来源

Hedge Funds With(out) Edge [点击浏览原文]

<摘要>

我提出了一个新的基准来评估对冲基金表现:做空CBOE波动率指数(VIX)期货的回报。这个基准的有效性引出了一个能够预测对冲基金表现的新方法。具体来说,它将对冲基金事先分为两类,一类能够提供更高的夏普比率和正偏度(夏普比率为0.52,偏度为4.30),另一类则夏普比率较低且偏度为负(夏普比率为0.15,偏度为-0.83)。我将前者称为具有优势的对冲基金,而后者则为没有优势的对冲基金。这种方法无法通过已知方法进行解释或复制。最后,我展示了我的实证发现可以通过包含具有外推预期的交易者模型来解释。”

回测表现

年化收益率8%
波动率15.38%
Beta0.158
夏普比率0.52
索提诺比率N/A
最大回撤N/A
胜率61%

完整python代码

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

Leave a Reply

Discover more from Quant Buffet

Subscribe now to keep reading and get access to the full archive.

Continue reading