投资宇宙包括“风险”投资组合(QQQ、IWD、GLD、IEF,各占25%)和“现金”投资组合(SHY、IWD、GLD、IEF,各占25%),所有资产等权重,并在每月最后一个交易日重新平衡。增长-趋势(GT)时机策略假设,当SPY的10日简单移动平均线(SMA10)和失业率的12日简单移动平均线(SMA12)均为负时,QQQ将被SHY替换。当SMA10再次看涨时,风险资产QQQ将替换现金资产SHY。动量过滤器的计算公式为MOMx = Px/AVERAGE(P_first)。

策略概述

<投资宇宙>

<增长-趋势(GT)时机>

假设 SPY 的 10 日简单移动平均线(SMA10)和失业率(UE)的 12 日简单移动平均线(SMA12)(再加上一个月的滞后)过滤器均为负数。在这种情况下,它会将‘近乎静态’的风险投资组合中的 QQQ 替换为现金投资组合中的 SHY,有效地在这两者之间切换——当崩盘信号到来时,我们只需要将 QQQ 替换为 SHY(两者各占投资组合的 25%)。动量过滤器计算公式为 MOMx = Px/AVERAGE(P_first),其中 P 是 x 期末的价格或 UE 值。有一个不对称规则:如果 SPY 的 SMA10 再次看涨,我们将引入风险资产 QQQ,替换现金资产 SHY。

策略合理性

该策略生成的最终投资组合与知名的等权重静态投资组合(如永久投资组合和黄金蝴蝶投资组合)类似。这些“全季节”投资组合在四种可能的经济条件——增长、衰退、通胀和通缩——下表现良好。此调整的主要优势在于,它适用于当今的大盘成长型 ETF QQQ,它在风险投资组合中替代了传统全季节投资组合中的大盘成长型 SPY,利用并从科技繁荣中获益。

论文来源

Growth-Trend Timing and 60-40 Variations: Lethargic Asset Allocation (LAA) [点击浏览原文]

<摘要>

哲学经济学的增长-趋势(GT)时机是一种出色的时机策略,只有当失业率(UE)和 SP500 指数的趋势均为熊市时,它才发出熊市信号。因此,它捕捉到了大部分市场下跌,同时在不到 15% 的时间内转向现金。就这一点而言,它的崩盘保护远没有我们在 DAA 策略中的“金丝雀”保护(25% 现金)或我们 VAA 策略中的广度保护(约 50% 现金)那么激进。在本文中,我们将 GT 时机应用于知名的 60-40 静态基准(60% SPY – 40% IEF),并在样本内搜索具有 GT 时机的 60-40 变体。对于这些变体,我们特别考虑了同样对通胀和收益率不敏感的风险投资组合,灵感来自永久投资组合及其衍生组合。我们的最终策略基于 GT 时机在两个静态投资组合之间切换。此策略称为迟缓资产配置(LAA)。”

回测表现

年化收益率10.5%
波动率8.5%
Beta0.48
夏普比率1.24
索提诺比率0.696
最大回撤-15%
胜率94%

完整python代码

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

class LethargicAssetsAllocation(QCAlgorithm):

    def Initialize(self) -> None:
        self.SetStartDate(2010, 1, 1)
        self.SetCash(100000)
        
        # set assets variables
        self.static_universe:List[str] = ['IWD', 'GLD', 'IEF']
        self.risky_asset:str = 'QQQ'
        self.safe_asset:str = 'SHY'
        
        self.spy_period:int = 10 * 21
        self.ue_period:int = 12

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

        for ticker in self.static_universe + [self.risky_asset, self.safe_asset]:
            self.AddEquity(ticker, Resolution.Daily)

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

        self.spy_sma = self.SMA(self.spy, self.spy_period, Resolution.Daily)
        self.ue_sma = self.SMA(self.ue, self.ue_period, Resolution.Daily)

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

        # rebalance when 'UE' data are in - monthly
        if data.ContainsKey(self.ue) and data.ContainsKey(self.spy) and data[self.spy] and data[self.ue]:
            # both SMA indicators are warmed up and ready
            if all(sma.IsReady for sma in [self.spy_sma, self.ue_sma]):
                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 negative - UE rate has risen
                    if data[self.spy].Value < self.spy_sma.Current.Value and data[self.ue].Value > self.ue_sma.Current.Value:
                        traded_universe += [self.safe_asset]
                    else:
                        traded_universe += [self.risky_asset]
                else:
                    # SPY trend is positive
                    if data[self.spy].Value > self.spy_sma.Current.Value:
                        if self.risky_asset not in traded_universe:
                            traded_universe = list(map(lambda x: x.replace(self.safe_asset, self.risky_asset), traded_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