VAA-G12风险投资组合包括12只全球ETF,如SPY、IWM、QQQ等,以及VAA现金投资组合(SHY、IEF和LQD)。双动量策略选择表现最佳的前T资产,使用等权重分配,同时用现金替换表现不佳的资产。为保护在市场崩盘中的表现,设定坏资产数量阈值B,决定现金比例CF。最优样本内VAA得分为T=2和B=4,投资组合每月按规定权重重新平衡,仅保留多头仓位。

策略概述

VAA-G12 风险投资组合的投资范围包括12只全球ETF:SPY、IWM、QQQ、VGK、EWJ、EEM、EFA、ACWX、IYR、GSG、GLD、SHY、IEF、TLT、LQD、HYG 和 AGG。VAA 现金投资组合由三种类似债券的资产组成:SHY、IEF 和 LQD(或30天国库券、中期政府债券和长期企业债券),并使用13612W相对动量筛选器来选择每个月表现最好的债券ETF。

双动量(Dual Momentum)策略在此定义为传统的双动量策略,在其中选择表现最好的前 T 资产,并使用等权重 w=1/T 分配权重,同时用现金替换掉那些表现不佳的资产(即具有非正的绝对动量的资产)。投资者使用响应性13612W(过去1、3、6和12个月的平均年化收益率)动量筛选器用于绝对动量。为了保护在市场崩盘中的表现(Crash Protection, CP),策略类似于PAA(保护性资产配置模型),在该模型中,投资者通过定义坏资产数量b(即动量非正的资产)来确定转向现金的程度。因此定义了广度保护阈值 B (简称 “广度 B”),即当坏资产数量达到B时,完全转向现金;同时使用 b/B 的比例(当 b<=B<=N)作为现金比例(CF)。

最优的样本内(1970-1993)VAA 得分为 T=2 和 B=4,并在整个样本期间(FS-完整样本)使用此设置。最终的投资组合仅为多头仓位,并根据规定的权重每月重新平衡。

策略合理性

与个别资产层面的绝对动量(趋势跟随)不同,VAA 使用市场整体的广度动量来进行崩盘保护,就像PAA(保护性资产配置模型)中所做的一样。然而,与PAA相比,作者现在使用市场中的坏资产数量(动量非正的资产)相对于广度保护阈值B(或简称广度B, B<=N)作为更精细的崩盘指标。如图所示,通过样本内优化,此广度B设置通常会在只有一个或少数资产表现不佳时提供100%的崩盘保护。这一设置提高了策略在熊市中的表现,从而提高了总体资产配置的表现。

论文来源

Breadth Momentum and Vigilant Asset Allocation (VAA): Winning More by Losing Less [点击浏览原文]

<摘要>

VAA(警惕资产配置)是一种基于双重动量的投资策略,具有强劲的崩盘保护和快速的动量筛选器。双重动量结合了绝对(趋势跟踪)和相对(强度)动量。与传统的双重动量方法相比,我们通过在宇宙层面使用广度动量替代了通常在资产层面的趋势跟踪崩盘保护。结果,VAA 策略平均情况下往往超过 50% 处于市场之外。然而,我们证明,结果动量策略绝非迟钝。通过使用包含美国和全球类似 ETF 的大宇宙和小宇宙的月度数据(从 1925 年和 1969 年开始),我们得出了超出样本的年化回报率超过 10%,最大回撤低于 15% 的结果。

回测表现

年化收益率15.6%
波动率10.2%
Beta0.078
夏普比率0.85
索提诺比率0.462
最大回撤-13%
胜率69%

完整python代码

from AlgorithmImports import *
from typing import List, Dict
#endregion

class VigilantAssetAllocation(QCAlgorithm):

    def Initialize(self) -> None:
        self.SetCash(100000)
        self.SetStartDate(2008, 1, 1)

        # VAA-G12 risk investment universe assets
        self.growth_symbols:List[str] = [
            "SPY", "IWM", "QQQ", "VGK", 
            "EWJ", "EEM", "EFA", "ACWX", 
            "IYR", "GSG", "GLD", "SHY", 
            "IEF", "TLT", "LQD", "HYG", "AGG"
            ]

        # cash universe assets
        self.safety_symbols:List[str] = ["LQD", "IEF", "SHY"]

        momentum_periods:List[int] = [21, 63, 126, 252]
        self.score_weights:np.ndarray = np.array([12, 4, 2, 1])

        self.SetWarmUp(momentum_periods[-1], Resolution.Daily)

        self.symbol_data:Dict[List] = {}
        self.B:int = 4
        self.T:int = 2
        self.leverage:int = 3

        for ticker in self.growth_symbols + self.safety_symbols:
            data:Equity = self.AddEquity(ticker, Resolution.Minute)
            data.SetLeverage(self.leverage)
            self.symbol_data[ticker] = [self.MOMP(ticker, period, Resolution.Daily) for period in momentum_periods]

        self.recent_month:int = -1
        
    def OnData(self, data:Slice) -> None:
        # monthly rebalance
        if self.recent_month == self.Time.month:
            return
        self.recent_month = self.Time.month

        if self.IsWarmingUp: return

        # this approach overweights the front month momentum value and progressively underweights older momentum values
        growth_score:Dict[str, float] = { ticker : sum(np.array([x.Current.Value for x in momentum_indicators]) * self.score_weights) for ticker, momentum_indicators in self.symbol_data.items() 
                                        if ticker in self.growth_symbols if all(ind.IsReady for ind in momentum_indicators) }
        
        safe_score:Dict[str, float] = { ticker : sum(np.array([x.Current.Value for x in momentum_indicators]) * self.score_weights) for ticker, momentum_indicators in self.symbol_data.items() 
                                        if ticker in self.safety_symbols if all(ind.IsReady for ind in momentum_indicators) }

        # wait until all the assets' indicators are warmed-up
        if len(growth_score) != len(self.growth_symbols) or len(safe_score) != len(self.safety_symbols):
            return

        sorted_growth:List = sorted(growth_score.items(), key=lambda x: x[1], reverse=True)
        sorted_safe:List = sorted(safe_score.items(), key=lambda x: x[1], reverse=True)

        # count the number of risky assets with negative momentum scores and store in b
        b:int = sum(1 for x in sorted_growth if x[1] < 0)
        CF:float = float(b) / float(self.B)
                
        # select two offensive asset with the highest score and allocate 25% of the portfolio to that asset at the close
        # pick two best from risky and one risk-free symbols
        best_growth:List[str] = [x[0] for x in sorted_growth][:self.T]
        best_safe:str = sorted_safe[0][0]
        
        # if more than four risky assets exhibit negative momentum scores, select the risk-free asset (LQD, IEF or SHY) with the highest score
        if b >= self.B:
            # liquidate
            invested:List[str] = [x.Key.Value for x in self.Portfolio if x.Value.Invested]
            for ticker in invested:
                if ticker != best_safe:
                    self.Liquidate(ticker)

            self.SetHoldings(best_safe, 1)
        
        # if none of the risky assets come back with negative momentum scores, allocation 100% to the 2 best scoring risky assets
        else:
            # liquidate
            invested:List[str] = [x.Key.Value for x in self.Portfolio if x.Value.Invested]
            for ticker in invested:
                if ticker not in best_growth + [best_safe]:
                    self.Liquidate(ticker)

            for ticker in best_growth:
                self.SetHoldings(ticker, (1-CF) / 2) 
            
            self.SetHoldings(best_safe, CF)

Leave a Reply

Discover more from Quant Buffet

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

Continue reading