投资宇宙包括进攻性资产(如SPY、QQQ等)、保护性资产和防御性资产(如TIP、BND等)。交易算法在每月最后一个交易日收盘时,根据动量选择相对动量最高的Top6资产,均等分配投资组合,并用BIL替换动量低于BIL的防御性资产。持有头寸至下个月最后一个交易日,并每月重新平衡。计算相对动量得分,选择防御性资产中相对和绝对动量均为正的Top3资产,若有负绝对动量则选择进攻性资产。

策略概述

投资宇宙包括进攻性资产:SPY、QQQ、IWM、VGK、EWJ、VWO、VNQ、DBC、GLD、TLT、HYG、LQD;保护性资产:SPY、VWO、VEA、BND;防御性资产:TIP、DBC、BIL、IEF、TLT、LQD、BND。

<交易算法>

在每个月最后一个交易日的收盘时,根据步骤2,从进攻性或防御性宇宙中选择相对动量值最高的Top6资产,并将投资组合的1/(Top6)分配给每个资产。将“表现不佳”的防御性选择(动量低于BIL的资产)替换为BIL。持有头寸直到下个月的最后一个交易日。无论是否有头寸变化,投资组合每月重新平衡一次。

计算进攻性和防御性资产宇宙中每个资产的相对动量得分,其中在t时刻的相对动量等于pt / SMA(12) – 1。SMA(12)的慢速趋势是基于月末数据计算的,最大滞后12个月,代表最近13个月末价格的平均值,包括当日。

从防御性资产宇宙中选择相对和绝对SMA(12)动量都为正的Top3资产,如果保护性(或“预警”)宇宙中的至少一个资产显示负的绝对动量,其中t时刻的绝对动量基于快速动量13612W(即1、3、6、12个月回报的加权平均值,权重分别为12、4、2、1)。否则,选择进攻性资产宇宙。

策略合理性

Keller正在发明一种新方法,结合了他之前作品中已经使用的绝对、相对和广度动量模型以及“崩盘保护”的主题。变化包括:a) 不同的动量过滤器,b) 基于“预警”宇宙概念的非常快速的“崩盘保护”,c) 将商品(DBC)加入防御性资产宇宙,不仅限于“现金”。

论文来源

Relative and Absolute Momentum in Times of Rising/Low Yields: Bold Asset Allocation (BAA) [点击浏览原文]

<摘要>

我们的目标是通过结合一些先前的模型,如保护性资产配置(PAA)、警戒性资产配置(VAA)和防御性资产配置(DAA),开发一种非常进攻性的战术资产配置策略。我们称这种新策略为“大胆资产配置”(BAA)。BAA结合了慢速的相对动量和快速的绝对动量与崩盘保护,基于“预警”宇宙的概念,当“预警”宇宙中的任何资产表现出负的绝对动量时,我们从进攻性资产转换为防御性资产。因此,BAA大约60%的时间都处于防御性资产中。通过增强防御性资产宇宙,使其超越现金,我们在1970年12月至2022年6月期间发现了非常可观的回报(>=20%),且月度最大回撤较低(<=15%)。

回测表现

年化收益率14.6%
波动率8.5%
Beta0.126
夏普比率1.72
索提诺比率0.487
最大回撤-8.7%
胜率68%

完整python代码

from AlgorithmImports import *
import pandas as pd
import numpy as np
from typing import List, Dict
from pandas.core.frame import DataFrame
# endregion

class BoldAssetAllocation(QCAlgorithm):
    
    def Initialize(self):
        self.SetCash(100000)
        self.SetStartDate(2008, 1, 1)

        # all assets
        self.offensive:List[str] = [
            "SPY", "QQQ",
            "IWM", "VGK",
            "EWJ", "VWO",
            "VNQ", "DBC",
            "GLD", "TLT",
            "HYG", "LQD",
        ]

        self.protective:List[str] = ["SPY", "VWO", "VEA", "BND"]
        self.defensive:List[str] = ["TIP", "DBC", "BIL", "IEF", "TLT", "LQD", "BND"]
        self.safe:str = "BIL"

        # strategy parameters (our implementation)
        self.prds:List[int] = [1, 3, 6, 12]                     # fast momentum settings
        self.prdweights:np.ndarray = np.array([12, 4, 2, 1])    # momentum weights
        self.LO, self.LP, self.LD, self.B, self.TO, self.TD = [
            len(self.offensive),
            len(self.protective),
            len(self.defensive),
            1,
            6,
            3,
        ]  # number of offensive, protective, defensive assets, threshold for "bad" assets, select top n of offensive and defensive assets
        
        self.hprd:int = (max(self.prds + [self.LO, self.LD]) * 21 + 50)  # momentum periods calculation

        # repeat safe asset so it can be selected multiple times
        self.all_defensive:List[str] = self.defensive + [self.safe] * max(
            0, self.TD - sum([1 * (e == self.safe) for e in self.defensive])
        )
        
        self.equities:List[str] = list(
            dict.fromkeys(self.protective + self.offensive + self.all_defensive)
        )
        
        leverage:int = 3
        for equity in self.equities:
            data:Equity = self.AddEquity(equity, Resolution.Daily)
            data.SetLeverage(leverage)

        self.recent_month:int = -1

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

        # monthly rebalance
        if self.recent_month == self.Time.month:
            return
        self.recent_month = self.Time.month

        # get price data and trading weights
        h:DataFrame = self.History(self.equities, self.hprd, Resolution.Daily)["close"].unstack(level=0)
        weights:pd.Series = self.trade_weights(h)

        # trade
        self.SetHoldings([PortfolioTarget(x, y) for x, y in zip(weights.index, weights.values) if x in data and data[x]])

    def trade_weights(self, hist:DataFrame) -> pd.Series:
        # initialize weights series
        weights:pd.Series = pd.Series(0, index=hist.columns)
        # end of month values
        h_eom:DataFrame = hist.loc[hist.groupby(hist.index.to_period("M")).apply(lambda x: x.index.max())].iloc[:-1, :]

        # Check if protective universe is triggered.

        # build dataframe of momentum values
        mom:DataFrame = (h_eom.iloc[-1, :].div(h_eom.iloc[[-p - 1 for p in self.prds], :], axis=0) - 1)
        mom = mom.loc[:, self.protective].T

        # determine number of protective securities with negative weighted momentum
        n_protective:float = np.sum(np.sum(mom.values * self.prdweights, axis=1) < 0)
        
        # % equity offensive
        pct_in:float = 1 - min(1, n_protective / self.B)

        # Get weights for offensive and defensive universes.

        # determine weights of offensive universe
        if pct_in > 0:
            # price / SMA
            mom_in = h_eom.iloc[-1, :].div(h_eom.iloc[[-t for t in range(1, self.LO + 1)]].mean(axis=0), axis=0)
            mom_in = mom_in.loc[self.offensive].sort_values(ascending=False)
            # equal weightings to top relative momentum securities
            in_weights = pd.Series(pct_in / self.TO, index=mom_in.index[:self.TO])
            weights = pd.concat([weights, in_weights])
        
        # determine weights of defensive universe
        if pct_in < 1:
            # price / SMA
            mom_out = h_eom.iloc[-1, :].div(h_eom.iloc[[-t for t in range(1, self.LD + 1)]].mean(axis=0), axis=0)
            mom_out = mom_out.loc[self.all_defensive].sort_values(ascending=False)
            # equal weightings to top relative momentum securities
            out_weights = pd.Series((1 - pct_in) / self.TD, index=mom_out.index[:self.TD])
            weights = pd.concat([weights, out_weights])

        weights:pd.Series = weights.groupby(weights.index).sum()

        return weights

Leave a Reply

Discover more from Quant Buffet

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

Continue reading