投资范围包括从CRSP提取的AMEX、NYSE和NASDAQ股票,样本期为2003年至2021年。重点是四分位反相关投注(qBAC)因子。首先,根据标准差对股票升序排序,并分为四个四分位。接着在每个四分位内,基于与S&P 500的相关性再次排序,分为低相关性和高相关性投资组合。做多低相关性股票,做空高相关性股票,按相关性加权,并每月再平衡。空头股票的权重为相关性,多头股票的权重为倒数相关性。通过计算多/空投资组合的风险调整回报获得四分位回报,最终定义qBAC因子为四分位风险调整回报的算术平均值。

策略概述

投资范围包括从CRSP中提取的AMEX、NYSE和NASDAQ股票,股票代码为10和11。样本期涵盖2003年至2021年。主要关注的变量是四分位反相关投注(qBAC)因子。首先,基于标准差对股票进行升序排序,然后将它们分配到四个四分位之一。在每个四分位内,基于股票与S&P 500的相关性再次升序排序,并将其分配到两个投资组合之一:低相关性和高相关性投资组合。在每个四分位中,使用中位数作为阈值,做多低相关性股票,做空高相关性股票。股票按相关性加权,每月再平衡。空头部分的每只股票的相关性权重定义为该股票的相关性除以所有其他股票的相关性总和。多头部分的相关性权重类似,但使用倒数相关性,定义为每只股票的倒数相关性除以所有其他股票的倒数相关性总和。然后通过计算每个四分位的多/空投资组合的风险调整回报(即倒数相关性权重/相关性权重乘以贝塔因子的倒数)来获得四分位的回报。最后,qBAC因子定义为各四分位风险调整回报的算术平均值。

策略合理性

根据CAPM模型,资本资产中存在正向的风险回报关系,经济主体倾向于选择每单位风险回报率最高的投资组合。然而,许多投资者由于杠杆限制无法这样做,导致对高风险资产的过度配置及其价格的高估。另一个对BAB因子的解释是行为效应,投资者追求高贝塔股票以获取额外溢价。然而,Bob Haugen(1962年)提出了风险回报关系的负向关系,使低贝塔股票比高贝塔股票对投资者更具吸引力。BAB因子无法通过任何Fama-French模型解释。BAC因子相比BAB中的另一个成分BAV因子,历史表现更为有趣。投资低相关性股票能够带来溢价。该策略在2016年底之前表现优于S&P 500基准,因为其多头持有防御性行业(公用事业、医疗保健和消费品),而空头持有周期性行业(工业、科技和金融)。在疫情期间,由于中央银行的干预以及科技股价格的显著上涨,导致该策略表现不及基准。

论文来源

The Low-Risk Effect, from Betting Against Beta to Betting Against Correlation [点击浏览原文]

<摘要>

本文的目的是分析所谓的“低风险效应”以及风险回报关系随时间的演变。或许现代金融的里程碑之一是关于资产配置中风险与回报之间正向关系的研究和讨论,但我们是否确定这一理论范式能够提供实证证据?从现代投资组合理论的“鼻祖”出发,经过Sharpe的CAPM模型和Black的1972年理论,我们深入分析了所谓的“低风险效应”,以及杠杆约束与行为理论之间的争论。最后,我们分析了Asness、Frazzini和Pedersen提出的反贝塔投注(BAB)并进一步研究了他们的最新贡献:反相关投注(BAC),即做多低相关性股票,做空高相关性股票。

回测表现

年化收益率7.32%
波动率27.48%
Beta0.23
夏普比率0.27
索提诺比率0.246
最大回撤-83.16%
胜率53%

完整python代码

from AlgorithmImports import *
from pandas.core.frame import DataFrame
# endregion

class BettingAgainstCorrelationInSP500Stocks(QCAlgorithm):

    def Initialize(self):
        self.SetStartDate(2000, 1, 1)
        self.SetCash(100000)
        
        self.volatility_period:int = 12*21
        self.correlation_period:int = 5*12*21
        self.quantile:int = 4
        self.portfolio_percentage:float = 1.
        
        self.prices:Dict[Symbol, RollingWindow] = {}
        self.weight:Dict[Symbol, float] = {}

        self.exchanges:List[str] = ['NYS', 'NAS', 'ASE']

        self.market_symbol:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
        self.prices[self.market_symbol] = RollingWindow[float](self.correlation_period)

        self.max_cap_weight:float = .1
        self.long_leg_corr_treshold:float = 0.
        self.long_leg_corr_substitute:float = .001

        self.fundamental_count:int = 1000
        self.fundamental_sorting_key = lambda x: x.DollarVolume

        self.selection_flag:bool = False
        self.UniverseSettings.Resolution = Resolution.Daily
        self.AddUniverse(self.FundamentalSelectionFunction)
        self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
        self.Schedule.On(self.DateRules.MonthStart(self.market_symbol), self.TimeRules.BeforeMarketClose(self.market_symbol, 0), self.Selection)

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

    def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
        # update daily prices
        for equity in fundamental:
            symbol:Symbol = equity.Symbol

            if symbol in self.prices:
                self.prices[symbol].Add(equity.AdjustedPrice)

        if not self.selection_flag:
            return Universe.Unchanged
        
        selected:List[Fundamental] = [x for x in fundamental if x.HasFundamentalData and x.Market == 'usa' and x.SecurityReference.ExchangeId in self.exchanges]
        if len(selected) > self.fundamental_count:
            selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]

        volatility:Dict[Symbol, list[float, list]] = {}
        stocks_returns:Dict[Symbol, np.ndarray] = {}

        # warm up stock prices
        for stock in selected:
            symbol:Symbol = stock.Symbol
            
            if symbol not in self.prices:
                self.prices[symbol] = RollingWindow[float](self.correlation_period)
                history:DataFrame = self.History(symbol, self.volatility_period, Resolution.Daily)
                if history.empty:
                    continue

                closes:pd.Series = history.loc[symbol].close

                for time, close in closes.items():
                    self.prices[symbol].Add(close)
            
            # make sure SPY prices are ready
            if not self.prices[self.market_symbol].IsReady:
                continue
            
            # calculate volatility and store daily returns
            if self.prices[symbol].IsReady:
                prices:np.ndarray = np.array([x for x in self.prices[symbol]])
                returns:np.ndarray = prices[:-1] / prices[1:] - 1
                vol_value:float = np.std(returns[:self.volatility_period])

                volatility[symbol] = vol_value
                stocks_returns[symbol] = returns

        # make sure enough stocks has volatility value
        if len(volatility) < self.quantile:
            return Universe.Unchanged

        quantile:int = int(len(volatility) / self.quantile)
        sorted_by_vol:List[Symbol] = [x[0] for x in sorted(volatility.items(), key=lambda item: item[1])]

        market_prices:np.ndarray = np.array([x for x in self.prices[self.market_symbol]])
        market_returns:np.ndarray = market_prices[:-1] / market_prices[1:] - 1

        # create long and short portfolio part
        for i in range(self.quantile):
            long_leg:List[tuple[Symbol, float]] = []
            short_leg:List[tuple[Symbol, float]] = []

            total_long_corr:float = 0
            total_short_corr:float = 0

            correlation:Dict[Symbol, float] = {}
            curr_quantile_stocks:List[Symbol] = sorted_by_vol[i * quantile : (i + 1) * quantile]

            for symbol in curr_quantile_stocks:
                stock_returns:np.ndarray = stocks_returns[symbol]
                correlation_matrix:np.ndarray = np.corrcoef(stock_returns, market_returns)
                corr_value:float = correlation_matrix[0][1]
                correlation[symbol] = corr_value

            corr_median:float = np.median(list(correlation.values()))

            for symbol, corr_value in correlation.items():
                # within each quartile we go long (short) low (high) correlation stocks using the median as a threshold
                if corr_value >= corr_median:
                    short_leg.append((symbol, corr_value))
                    total_short_corr += abs(corr_value)
                else:
                    if corr_value < self.long_leg_corr_treshold:
                        corr_value = self.long_leg_corr_substitute

                    long_leg.append((symbol, corr_value))
                    total_long_corr += 1 / abs(corr_value)

            # weights calculations
            for i, portfolio in enumerate([long_leg, short_leg]):
                for symbol, corr_value in portfolio:
                    w:float = ((1 / corr_value) / total_long_corr) * (1 / self.quantile) * self.portfolio_percentage
                    w = min(self.max_cap_weight, w) # weight cap
                    self.weight[symbol] = ((-1) ** i) * w
                    
        return list(self.weight.keys())

    def OnData(self, data: Slice) -> None:
        # rebalance monthly
        if not self.selection_flag:
            return
        self.selection_flag = False
        
        # trade execution
        portfolio:List[PortfolioTarget] = [PortfolioTarget(symbol, w) for symbol, w in self.weight.items() if symbol in data and data[symbol]]
        self.SetHoldings(portfolio, True)

        self.weight.clear()
                
    def Selection(self) -> None:
        self.selection_flag = True
        
# Custom fee model
class CustomFeeModel(FeeModel):
    def GetOrderFee(self, parameters):
        fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
        return OrderFee(CashAmount(fee, "USD"))

Leave a Reply

Discover more from Quant Buffet

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

Continue reading