投资宇宙包括两种组合:a) 由QQQ、IWN、IEF、TLT和GLD组成的等权重高风险投资组合(每种20%);b) 作为“风险规避”的国债组合(IEF和TLT各50%)。每月计算失业率的12个月回报率过滤器(UE1)。当失业率呈现看跌趋势时,使用13612W动量过滤器评估预警资产VWO和BND的市场趋势。若失业率和预警资产均为看跌,则投资于等权重的现金组合(IEF和TLT各50%);否则,投资于高风险组合(QQQ、GLD、IEF、TLT、IWN各20%)。这样,当DAA的预警趋势和失业率趋势均为看跌时,进入“现金”状态。

策略概述

<投资宇宙>

a) 由5种资产组成的等权重(每种20%)的近静态高风险RAA投资组合:QQQ、IWN、IEF、TLT、GLD;该组合切换为
b) 国债(IEF和TLT;各占50%)作为“风险规避”的“现金”资产,使用GT UE1/DAA时机。

<构建步骤>

  1. 每月计算失业率(UE)的12个月回报率过滤器:UE1(t) = UE(t)/UE(t-12)-1;其中UE(t)为月份t的月度失业率(%),有一个月的发布延迟。
  2. 当失业率趋势(见步骤1)呈现看跌(失业率高于12个月前)时,使用快速的13612W动量过滤器计算“预警”资产VWO和BND的市场趋势。13612W动量过滤器是1、3、6、12个月滞后回报的加权平均值,各自加权到年回报(权重分别为12x、4x、2x和1x)。
  3. 当失业率趋势(见步骤1)和一个或两个预警资产VWO和BND的市场趋势(见步骤2)均为看跌时,投资于等权重2资产的现金组合(IEF和TLT,各50%)。
  4. 否则,投资于等权重5资产的静态高风险组合(QQQ、GLD、IEF、TLT、IWN,各20%)。

因此,当DAA的预警趋势和失业率趋势均为看跌时,我们将进入“现金”(等权重2组合:IEF和TLT)。

策略合理性

对于RAA,他们结合了其他永久性投资组合的一些元素(例如Golden Butterfly和All Weather投资组合)以及我们之前策略中的一些元素(例如防御性资产配置[DAA][Keller 2018])。该策略的主要思想是能够在所有四种经济环境中(通胀/增长上升或下降)表现出色,并尽可能平衡风险权重。在50年的完整回测中,RAA显示出优秀的年度回报,并显著减少最大(月度)回撤,约为传统静态60/40投资组合的三分之一。

论文来源

Lazy Momentum with Growth-Trend timing: Resilient Asset Allocation (RAA) [点击浏览原文]

<摘要>

韧性资产配置(RAA)是我们惰性资产配置(LAA)策略的更具进攻性的版本。它结合了更加稳健的“全天候”投资组合,具有更慢的增长趋势(GT)过滤器和更快速的市场崩盘保护。GT时机仅在美国失业率(UE)和资本市场均为看跌时转向风险规避。为了实现RAA,我们通过三步调整LAA。首先,改变了(高风险、近静态)的投资组合,转向更稳健且更加多元化的“全天候”投资组合,现在包含五种等权重资产(而非四种),并仅将债券作为风险规避资产(“现金”)。其次,使用了我们防御性资产配置(DAA)论文中的“预警”技术,以更快的过滤器确定市场趋势。第三,我们将失业率趋势过滤器更改为较慢的过滤器,简单地比较最近的失业率与一年前的失业率。因此,RAA比LAA更具进攻性且更加稳健,同时在交易和换手率方面几乎与LAA一样“懒惰”(平均每年交易一个月)。

回测表现

年化收益率12.3%
波动率8.9%
Beta0.328
夏普比率1.38
索提诺比率0.515
最大回撤-11.8%
胜率89%

完整python代码

from AlgorithmImports import *
from typing import List
from dateutil.relativedelta import relativedelta, FR
# endregion

class ResilientAssetAllocation(QCAlgorithm):

    def Initialize(self) -> None:
        self.SetStartDate(2010, 1, 1)
        self.SetCash(100000)
        
        # set assets variables
        self.static_universe:List[str] = ['QQQ', 'IWN', 'IEF', 'TLT', 'GLD']
        self.cash_universe:List[str] = ['IEF', 'TLT']
        self.canary_universe:List[str] = ['VWO', 'BND']

        self.ue_period:int = 12

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

        self.symbol_data:Dict[List] = {}

        # warm up of indicators
        self.SetWarmup(self.ue_period * 31, Resolution.Daily)

        for ticker in self.static_universe + self.cash_universe:
            self.AddEquity(ticker, Resolution.Daily)

        for ticker in self.canary_universe:
            self.AddEquity(ticker, Resolution.Daily)            
            self.symbol_data[ticker] = [self.MOMP(ticker, period, Resolution.Daily) for period in momentum_periods]

        # SMA assets
        self.ue:Symbol = self.AddData(UnemploymentRate, 'UE', Resolution.Daily).Symbol

        self.ue_window = RollingWindow[float](12)

    def OnData(self, data: Slice) -> None:
        if self.IsWarmingUp:
            return

        # calculate the momentum scores of the canary symbols
        canary_score:List[float] = [np.dot(np.array([momentum.Current.Value for momentum in item[1]]), self.score_weights) \
                        for item in self.symbol_data.items() if item[0] in self.canary_universe and \
                        all(indicator.IsReady for indicator in item[1])]
  
        # wait until all the assets' indicators are warmed-up
        if len(canary_score) != len(self.canary_universe):
            return

        # rebalance when 'UE' data are in - monthly
        if data.ContainsKey(self.ue) and data[self.ue]:
 
            self.ue_window.Add(data[self.ue].Value)

            if not self.ue_window.IsReady:
                return
    
            if self.ue_window.IsReady:
                invested_tickers:List[str] = [x.Key.Value for x in self.Portfolio if x.Value.Invested]
                traded_universe:List[str] = self.static_universe if not self.Portfolio.Invested else invested_tickers

                # change allocation of assets
                if not self.Portfolio.Invested:
                    # both trends are positive
                    if data[self.ue].Value > self.ue_window[self.ue_window.Count-1] and canary_score[0] < 0 and canary_score[1] < 0:
                        traded_universe = self.cash_universe
                    else:
                        traded_universe = self.static_universe
               
                # firstly, liquidate symbols that should not be held
                for ticker in invested_tickers:
                    if ticker not in traded_universe:
                        self.Liquidate(ticker)
                
                # rebalance new portfolio
                for ticker in traded_universe:
                    if ticker in data and data[ticker]:
                        self.SetHoldings(ticker, 1 / len(traded_universe))
        else:
            if self.Portfolio.Invested:
                last_update_date:datetime.date = UnemploymentRate.get_last_update_date()
                
                # custom data stopped comming in
                if self.Securities[self.ue].GetLastData() and self.Time.date() > last_update_date:
                    self.Liquidate()

class UnemploymentRate(PythonData):
    def GetSource(self, config: SubscriptionDataConfig, date: datetime, isLiveMode: bool) -> SubscriptionDataSource:
        return SubscriptionDataSource('data.quantpedia.com/backtesting_data/economic/UNEMPLOYMENT_RATE.csv', SubscriptionTransportMedium.RemoteFile, FileFormat.Csv)

    _last_update_date:datetime.date = datetime(1,1,1).date()

    @staticmethod
    def get_last_update_date() -> datetime.date:
       return UnemploymentRate._last_update_date

    def Reader(self, config: SubscriptionDataConfig, line: str, date: datetime, isLiveMode: bool) -> BaseData:
        data = UnemploymentRate()
        data.Symbol = config.Symbol

        if not line[0].isdigit(): return None
        split = line.split(';')
        
        # Parse the CSV file's columns into the custom data class - first Friday of the month
        data.Time = (datetime.strptime(split[0], '%Y-%m-%d').date() + relativedelta(weekday=FR(1))) + timedelta(days=1)

        if data.Time.date() > UnemploymentRate._last_update_date:
            UnemploymentRate._last_update_date = data.Time.date()

        data.Value = float(split[1])
        
        return data

Leave a Reply

Discover more from Quant Buffet

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

Continue reading