“该策略投资于大盘股,基于CAPM估计的阿尔法,做空高阿尔法投资组合,做多低阿尔法投资组合,采用加权排名,并每年重新平衡,以系统性地区分业绩。”

I. 策略概要

该策略的目标是纽约证券交易所、纳斯达克和美国证券交易所的普通股,不包括房地产投资信托基金(REITs)和美国存托凭证(ADRs)。股票按纽约证券交易所的断点分为三组:小盘股(市值≤第30百分位)、中盘股(市值>第30至≤第70百分位)和大盘股(市值>第70百分位)。仅使用大盘股数据集。

在每年的年底,使用五年数据的CAPM回归估计阿尔法和贝塔。中值阿尔法决定投资组合的构建:做空高阿尔法股票,做多低阿尔法股票。投资组合每年重新平衡。

资产权重基于阿尔法排名,确保两个投资组合的权重总和等于1。个别资产的阿尔法通过资产和市场回报之间的相关性计算,乘以资产的波动率,再除以市场波动率,并使用加权因子(个别阿尔法为0.6,加上常数0.4)进行调整。

低阿尔法投资组合的权重随着阿尔法排名的升高而降低,而高阿尔法投资组合的权重与排名成正比。最终投资组合的阿尔法计算为个别市场阿尔法的加权总和。虽然权重类似于BAA、BAB和BAAB因子,但该策略强调实用性,而不是最大化夏普比率。这种系统性的方法为区分大盘股投资组合中的高阿尔法和低阿尔法股票提供了一个稳健的框架。

II. 策略合理性

受到杠杆约束的投资者高估高市场贝塔股票,同样,他们抬高具有高非市场因子贝塔的资产,将其视为正的CAPM阿尔法。研究表明,投资者优先考虑市场风险,并将非市场风险因子解释为优异的表现。基金流动对高CAPM阿尔法做出积极反应,促使基金经理将投资组合倾向于高非市场贝塔资产,以吸引关注市场风险的投资者。旨在最大化相对于指数的信息比率的经理人,有动机选择高阿尔法资产,因为这些资产可以提高比率分子,而不会显著增加跟踪误差。

该策略与买入高阿尔法股票和卖出低阿尔法股票的行业轮动策略形成对比,主要在方法上有所不同。行业轮动使用36个月的回溯期和一个月的持有期,而这种方法依赖于五年回溯期和一年持有期。对非市场贝塔的关注提供了独特的视角,利用长期阿尔法生成机会,同时解决投资组合管理中的套利限制和基准激励问题。

III. 来源论文

Betting Against Alpha [点击查看论文]

<摘要>

我基于从CAPM、Carhart(1997)和Fama-French五因子(FF5,2015)模型估计的已实现阿尔法对股票进行排序,发现已实现阿尔法与未来股票回报、未来阿尔法和夏普比率呈负相关。因此,我构建了一个“押注反对阿尔法”(BAA)因子,该因子买入低阿尔法股票投资组合,卖出高阿尔法股票投资组合。使用排名估计方法,我表明BAA因子跨越了与Frazzini和Pedersen(2014)的“押注反对贝塔”(BAB)因子不同的股票回报维度。此外,BAA因子捕获了CAPM、Carhart和FF5模型遗漏的股票回报横截面信息。如果低阿尔法投资组合是从低贝塔股票计算的,而高阿尔法投资组合是从高贝塔股票计算的,则BAA因子的表现会进一步提高。我将这个因子称为“押注反对阿尔法和贝塔”(BAAB)。我讨论了支持这种违反直觉的低阿尔法异常现象存在的几个原因。

IV. 回测表现

年化回报0.24%
波动率36.48%
β值0.039
夏普比率0.08
索提诺比率0.056
最大回撤N/A
胜率56%

V. 完整的 Python 代码

from AlgorithmImports import *
from scipy import stats
from typing import List, Dict
class BettingAgainstAlpha(QCAlgorithm):
    
    def Initialize(self):
        self.SetStartDate(2000, 1, 1)
        self.SetCash(100000)
        self.exchange_codes:List[str] = ['NYS', 'NAS', 'ASE']
        self.leverage:int = 10
        self.period:int = 5 * 12 * 21
        self.selection_month_count:int = 12
        # Market data and consolidator.
        self.symbol:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
        # Daily price data.
        self.data:Dict[Symbol, RollingWindow] = {}
        
        # Market monthly data.
        self.data[self.symbol] = RollingWindow[float](self.period)
            
        self.weight:Dict[Symbol, float] = {}
        
        self.fundamental_count:int = 1000
        self.fundamental_sorting_key = lambda x: x.DollarVolume
        
        self.month:int = 12
        self.selection_flag:bool = True
        self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
        self.UniverseSettings.Resolution = Resolution.Daily
        self.AddUniverse(self.FundamentalSelectionFunction)
        self.Schedule.On(self.DateRules.MonthEnd(self.symbol), self.TimeRules.AfterMarketOpen(self.symbol), self.Selection)
        self.settings.daily_precise_end_time = False
    def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
        for security in changes.AddedSecurities:
            security.SetFeeModel(CustomFeeModel())
            security.SetLeverage(self.leverage)
    def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
        # Update the rolling window every day.
        for stock in fundamental:
            symbol:Symbol = stock.Symbol
            
            # Store monthly price.
            if symbol in self.data:
                self.data[symbol].Add(stock.AdjustedPrice)
        if not self.selection_flag:
            return Universe.Unchanged
        # selected = [x.Symbol for x in fundamental if x.HasFundamentalData and x.Market == 'usa']
        selected:List[Fundamental] = [x for x in fundamental if x.HasFundamentalData and x.Market == 'usa' and x.MarketCap != 0 \
                                    and x.CompanyReference.IsREIT != 1 and x.SecurityReference.ExchangeId in self.exchange_codes]
        if len(selected) > self.fundamental_count:
            selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]
        
        # Warmup price rolling windows.
        for stock in selected:
            symbol:Symbol = stock.Symbol
            if symbol in self.data:
                continue
            
            self.data[symbol] = RollingWindow[float](self.period)
            history:dataframe = self.History(symbol, self.period, Resolution.Daily)
            if history.empty:
                self.Log(f"Not enough data for {symbol} yet.")
                continue
            closes:Series = history.loc[symbol].close
            for time, close in closes.items():
                self.data[symbol].Add(close)
        
        market_returns:List[float] = []
        if self.data[self.symbol].IsReady:
            market_closes:np.ndarray = np.array([x for x in self.data[self.symbol]])
            market_returns = (market_closes[:-1] - market_closes[1:]) / market_closes[1:]
        
        alpha_data:Dict[Symbol, float] = {}
        
        if len(market_returns) != 0:
            for stock in selected:
                symbol:Symbol = stock.Symbol
                if not self.data[symbol].IsReady:
                    continue
                stock_closes:np.ndarray = np.array([x for x in self.data[symbol]])
                stock_returns:np.ndarray = (stock_closes[:-1] - stock_closes[1:]) / stock_closes[1:]
                
                beta, alpha, r_value, p_value, std_err = stats.linregress(market_returns, stock_returns)
                alpha_data[symbol] = alpha
        
        if len(alpha_data) != 0: 
            # Alpha diff calc.
            alpha_median:float = np.median([x[1] for x in alpha_data.items()])
            high_alpha_diff:List[List[Symbol, float]] = [[x[0], x[1] - alpha_median] for x in alpha_data.items() if x[1] > alpha_median]
            low_alpha_diff:List[List[Symbol, float]] = [[x[0], alpha_median - x[1]] for x in alpha_data.items() if x[1] < alpha_median]
            # Alpha diff weighting.
            for i, portfolio in enumerate([low_alpha_diff, high_alpha_diff]):
                diff_sum:float = sum(list(map(lambda x: x[1], portfolio)))
                for symbol, diff in portfolio:
                    self.weight[symbol] = ((-1)**i) * (diff / diff_sum)
        return list(self.weight.keys())
        
    def OnData(self, data: Slice) -> None:
        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:
        if self.month == self.selection_month_count:
            self.selection_flag = True
        self.month += 1
        if self.month > 12:
            self.month = 1
# Custom fee model.
class CustomFeeModel(FeeModel):
    def GetOrderFee(self, parameters):
        fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
        return OrderFee(CashAmount(fee, "USD"))

发表评论

了解 Quant Buffet 的更多信息

立即订阅以继续阅读并访问完整档案。

继续阅读