投资宇宙由Fama和French的市场因子(MKT)和一个月期国库券(RF)组成。构建三种趋势交易信号:若MKT高于200日移动平均线,天真信号买入或持有;RR信号基于Rachev比率,右尾均值高于左尾时买入;若10年期国债收益率高于3个月期国债收益率,YC信号买入。构建三个宏观经济信号:实际零售销售、工业生产和房屋开工增长为正时,分别买入或持有MKT。交易规则为:若所有趋势信号或宏观信号均为正,继续持有MKT;否则退出股市持有RF。策略仅在趋势信号和宏观信号同时为负时退出。

策略概述

投资宇宙由Fama和French的市场因子(MKT)和一个月期国库券(RF)组成。首先,构建三种趋势交易信号,策略如下:如果MKT价格高于其200日移动平均线,天真信号将买入或继续持有MKT。RR基于Rachev比率绩效指标,当过去200天中MKT超额回报的右尾(高于中位数部分)的均值大于左尾(低于中位数部分)的均值时,买入或继续持有MKT。如果10年期国债收益率高于3个月期国债收益率,YC将买入或继续持有MKT。接下来,构建三个宏观经济交易信号。若上个月的实际零售销售增长(同比)为正,RSALES买入或继续持有MKT。若上个月的工业生产增长(同比)为正,INDPROD买入或继续持有MKT。若上个月的房屋开工增长(同比)为正,HOUSE买入或继续持有MKT。最后,该策略的交易规则如下:若天真、RR和YC交易信号均为正,或INDPROD、RSALES和HOUSE信号均为正,则继续持有MKT,否则退出股市并持有RF。请注意,宏观经济交易信号的目的是在经济强劲时将策略保持在股市中,不论MKT的轻微价格波动。因此,只有当至少一个趋势信号(天真、RR、YC)和一个宏观信号(RSALES、INDPROD、HOUSE)同时为负时,策略才会退出股市。然而,仅有正的趋势信号(天真、RR、YC全为正)即可迫使策略重返股市,而单靠正的宏观信号(RSALES、INDPROD、HOUSE全为正)不足以促使策略返回股市。

策略合理性

该策略使用基于200日简单移动平均线(天真)和Rachev比率(RR)的交易信号,这两者共同提供了股票市场趋势的多样化交易信号。这些信号与一个领先指标收益率曲线(YC)信号结合,能够提前预测衰退和熊市。此外,该策略还依赖于宏观经济交易信号,即实际零售销售增长(RSALES)、工业生产增长(INDPROD)和房屋开工增长(HOUSE),旨在减少不必要的退出,并在经济强劲时保持策略在市场中的投资,而不管股价的轻微波动。在这种设置中,当天真和RR信号之一转为负面时,策略不仅退出市场,还会在YC转为负面时退出市场,前提是至少一个宏观信号也为负。通过这种方式,策略能够避免熊市初期的下跌,因为YC会在熊市开始前将其关闭,因此不必等到天真或RR转为负面才退出。这是因为YC在趋势和宏观经济信号之前就已经转为负面,作为衰退和熊市的领先指标。如果天真、RR或YC产生了虚假的负面信号,只要经济强劲(即INDPROD、RSALES和HOUSE信号共同为正),策略仍然会保持在股市中。

论文来源

Avoid Equity Bear Markets with a Market Timing Strategy [点击浏览原文]

<摘要>

本文的目标是构建一种能够在熊市期间可靠地规避股市的市场时机策略,从而减少市场波动性并提高风险调整后的回报。我们基于价格指标、宏观经济指标和领先指标(收益率曲线)构建了交易信号,这些指标能够提前预测衰退和熊市。我们表现最佳的策略使用了国债利差、200日移动平均线和一种替代风险指标Rachev比率作为趋势指标,同时使用了实际零售销售增长、工业生产增长和房屋开工增长作为宏观经济指标。根据从1927年到2023年的样本期,该策略的年超额回报率为6.59%,同时市场夏普比率几乎翻倍至0.56,最大回撤减少了三分之二至-25.13%。

回测表现

年化收益率6.59%
波动率11.87%
Beta0.642
夏普比率0.56
索提诺比率0.26
最大回撤-25.13%
胜率74%

完整python代码

from AlgorithmImports import *
from typing import List
from pandas.core.frame import DataFrame
import data_tools
# endregion

class AvoidEquityBearMarketswithaMarketTimingStrategy(QCAlgorithm):

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

        self.period:int = 365
        self.moving_average_period:int = 200
        self.leverage:int = 5

        # custom data subscription
        self.market_sec:Security = self.AddData(data_tools.MarketEQ, 'MKT', Resolution.Daily)
        self.t10y3m:Symbol = self.AddData(data_tools.TreasureBill, 'T10Y3M', Resolution.Daily).Symbol
        self.rrsfs:Symbol = self.AddData(data_tools.RRSFS, 'RRSFS_YOY', Resolution.Daily).Symbol
        self.indpro:Symbol = self.AddData(data_tools.IndustrialProduction, 'INDPRO_YOY', Resolution.Daily).Symbol
        self.house_started:Symbol = self.AddData(data_tools.HouseStarted, 'HOUST_YOY', Resolution.Daily).Symbol

        self.signal_market:Symbol = self.market_sec.Symbol
        self.traded_market:Symbol = self.AddEquity("SPY", Resolution.Daily).Symbol
        self.bill:Symbol = self.AddEquity("SHY", Resolution.Daily).Symbol

        for symbol in [self.traded_market, self.bill]:
            self.Securities[symbol].SetLeverage(self.leverage)

        self.rebalance:bool = False

        self.Schedule.On(self.DateRules.MonthEnd(self.bill), self.TimeRules.BeforeMarketClose(self.bill), self.Selection)

    def OnData(self, data: Slice) -> None:
        if not self.rebalance:
            return
        self.rebalance = False

        market_last_update_date:datetime.date = data_tools.MarketEQ._last_update_date
        t10y3m_last_update_date:datetime.date = data_tools.TreasureBill._last_update_date
        rrsfs_last_update_date:datetime.date = data_tools.RRSFS._last_update_date
        indpro_last_update_date:datetime.date = data_tools.IndustrialProduction._last_update_date
        house_started_last_update_date:datetime.date = data_tools.HouseStarted._last_update_date

        # call history on assets
        symbols:List[Symbol] = [self.t10y3m, self.rrsfs, self.indpro, self.house_started]
        history:DataFrame = self.History(symbols, self.period, Resolution.Daily)['value'].unstack(level=0)
        
        history_market:DataFrame = self.History([self.signal_market], self.period, Resolution.Daily)['value']
        
        if len(history.dropna().iloc[-3:]) != 3 or history.dropna().iloc[-3:].isnull().values.any() or not all(x in list(history.columns) for x in symbols): 
            return

        if len(history) >= self.moving_average_period:
            history.reset_index(inplace=True)
            history.set_index('time', inplace=True)
            market_returns:DataFrame = history_market.iloc[-self.moving_average_period:].pct_change()
            
            rr:float = self.rachev_ratio(market_returns.iloc[1:], 0.5)

            trend_signal:bool = False
            macro_signal:bool = False

            # trend signal evaluation
            if history_market.iloc[-1] > history_market.iloc[-self.moving_average_period:].mean() and rr >= 1. \
                and history[self.t10y3m].iloc[-1] > 0:
                trend_signal = True

            if (self.Securities[self.signal_market].GetLastData() and self.Time.date() >= market_last_update_date) or \
                (self.Securities[self.t10y3m].GetLastData() and self.Time.date() >= t10y3m_last_update_date) or \
                (self.Securities[self.rrsfs].GetLastData() and self.Time.date() >= rrsfs_last_update_date) or \
                (self.Securities[self.indpro].GetLastData() and self.Time.date() >= indpro_last_update_date) or \
                (self.Securities[self.house_started].GetLastData() and self.Time.date() >= house_started_last_update_date):
                
                self.Liquidate()
                return

            # macro signal evaluation
            if history[self.rrsfs].dropna().iloc[-2] > 0 and history[self.indpro].dropna().iloc[-2] > 0 and history[self.house_started].dropna().iloc[-2] > history[self.house_started].dropna().iloc[-3]:
                macro_signal = True

            if self.traded_market in data and data[self.traded_market] and self.bill in data and data[self.bill]:
                traded_asset:Symbol = self.traded_market

                if not self.Portfolio.Invested:
                    if trend_signal:
                        traded_asset:Symbol = self.traded_market
                    else:
                        traded_asset = self.bill

                elif self.Portfolio[self.bill].Invested and trend_signal:
                    traded_asset = self.traded_market
                elif self.Portfolio[self.traded_market].Invested and not trend_signal and not macro_signal:
                    traded_asset = self.bill
                
                if not self.Portfolio[traded_asset].Invested:
                    self.Liquidate()
                    self.SetHoldings(traded_asset, 1)

    def Selection(self) -> None:
        self.rebalance = True
    
    def rachev_ratio(self, df:DataFrame, alpha=0.5) -> float:
        # calculate VaR for left and right tails
        left_var:float = df.quantile(alpha)
        right_var:float = df.quantile(1 - alpha)
        
        # calculate Expected Shortfall for left and right tails
        left_es:float = df[df <= left_var].mean()
        right_es:float = df[df > right_var].mean()
        
        # calculate the Rachev Ratio
        rr:float = right_es / -left_es
        
        return rr

Leave a Reply

Discover more from Quant Buffet

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

Continue reading