该策略聚焦于美国上市的ETF和杠杆ETF(LETF),数据来源于CRSP和ETFG数据库。每只证券在组合构建前一年内需至少有240个观测值。每月在构建前一天,计算LETF与ETF的相关性,并配对具有最大绝对相关性的证券。调整所有ETF至相同的杠杆水平。若相关性为正,卖出LETF并买入对应的ETF;若为负,则同时卖出两者。最终构建等权重投资组合。

策略概述

投资领域由在美国上市的ETF和杠杆ETF(LETF)构成。数据来自CRPS和ETFG数据库。每只证券在组合构建前一年内至少有240个观测数据,且组合构建前一天的数据可用。仅考虑正或负杠杆的双倍和三倍LETF。

首先,每个月在组合构建前一天,使用过去一年的日收益率计算所有LETF和ETF之间的相关性。其次,将每个LETF与其展示出最大绝对相关性的ETF配对。创建配对时,要求至少50%的绝对相关性。然后,将所有ETF调整到与其配对LETF相同的杠杆水平。第三,每个月,如果相关性为正,则卖出LETF并买入对应的配对ETF;如果相关性为负,则同时卖出LETF和配对的ETF。最终,构建一个由所有ETF-LETF对等权重组成的投资组合。

策略合理性

该策略的主要功能是利用LETF和相同杠杆级别的基础ETF在多日内的回报差异。通过一个简单的例子可以理解这一点。在第零天,ETF的价值为100,一个追踪该ETF的双倍LETF自身拥有100的现金资本并有200的总回报掉期敞口。第一天,ETF增加10%,随后第二天减少10%。第二天ETF的价值等于99。而LETF在第一天增加20%,在第二天减少20%。第二天LETF的资本价值为96。总结来说,2倍杠杆的ETF减少2%,而LETF减少4%。这种现象被称为LETF滑点效应。

论文来源

Leveraged ETPs Across Asset Classes [点击浏览原文]

<摘要>

杠杆交易所交易产品(LETP)的月度回报与其基础的杠杆交易所交易产品(ETP)不同,这种现象被称为LETP滑点效应。本文研究了股权发达市场、股权新兴市场、商品、固定收益和货币市场五个资产类别的LETP滑点效应。高波动性资产类别表现出比低波动性资产类别更大的滑点效应。在横截面上,通过波动性度量对LETP进行排序,会使滑点效应更加显著。一个由流动性和高波动性LETP构成的投资组合年化风险调整回报率为12.50%。此外,LETP滑点与同一资产类别的ETP市场投资组合的相关性为零或负相关。因此,LETP滑点可以作为与广泛市场指数组合时的分散化工具。

回测表现

年化收益率4.92%
波动率2.56%
Beta0.025
夏普比率1.92
索提诺比率-1.173
最大回撤N/A
胜率48%

完整python代码

from AlgorithmImports import *
from typing import Dict, List, Set
from data_tools import CustomFeeModel, TradePair, SymbolData
# endregion

class ArbitragingLeveredETFs(QCAlgorithm):

    def Initialize(self):
        self.SetStartDate(2010, 1, 1)
        self.SetCash(100000)
        
        self.leverage:int = 5
        self.corr_threshold:float = 0.5

        self.period:int = 12 * 21
        
        self.data:Dict[Symbol, SymbolData] = {}
        self.trades:List[List[Symbol, float]] = []

        self.l_etfs:Dict[str, float] = {}

        l_etf_tickers_csv:str = self.Download('data.quantpedia.com/backtesting_data/equity/leveraged_etf_tickers.csv')
        lines:List[str] = l_etf_tickers_csv.split('\r\n')

        for line in lines[1:]:
            if line == '':
                continue

            line_split:List[str] = line.split(';')
            ticker:str = line_split[0]
            leverage:float = float(line_split[-1])

            self.l_etfs[ticker] = leverage
        
        etf_tickers_csv:str = self.Download('data.quantpedia.com/backtesting_data/equity/not_leveraged_etf_tickers.csv')
        lines:List[str] = etf_tickers_csv.split('\r\n')
        self.etfs:List[str] = { ticker: 1 for ticker in lines[1:] if ticker != '' }

        self.market_symbol:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol

        self.selection_flag:bool = False
        self.UniverseSettings.Resolution = Resolution.Daily
        self.AddUniverse(self.FundamentalSelectionFunction)
        
        self.Schedule.On(self.DateRules.MonthStart(self.market_symbol), self.TimeRules.BeforeMarketClose(self.market_symbol, 0), self.Selection)

    def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
        for security in changes.AddedSecurities:
            security.SetFeeModel(CustomFeeModel())
            security.SetLeverage(self.leverage)

    def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
        for stock in fundamental:
            symbol:Symbol = stock.Symbol

            if symbol in self.data:
                self.data[symbol].update_prices(stock.AdjustedPrice)
        
        if not self.selection_flag:
            return Universe.Unchanged

        selected_leveraged_symbols:List[Symbol] = []
        selected_symbols:List[Symbol] = []

        for stock in fundamental:
            symbol:Symbol = stock.Symbol
            ticker:str = symbol.Value

            if ticker in self.etfs:
                selected_symbols.append(symbol)

            elif ticker in self.l_etfs:
                selected_leveraged_symbols.append(symbol)

            if symbol not in self.data:
                self.data[symbol] = SymbolData(self.period)
                history:pd.DataFrame = self.History(symbol, self.period, Resolution.Daily)

                if history.empty:
                    continue

                closes:pd.Series = history.loc[symbol].close

                for _, close in closes.items():
                    self.data[symbol].update_prices(close)

        trade_pairs:List[TradePair] = []
        symbols_to_trade:Set[Symbol] = set()
        abs_corr_by_symbol:Dict[Symbol, Tuple] = {}

        # pair each LETF to the ETF and calculate their correlation based on daily returns in one year
        for l_symbol in selected_leveraged_symbols:
            if not self.data[l_symbol].is_ready():
                continue

            leveraged_daily_returns:np.array = self.data[l_symbol].get_daily_returns()

            for symbol in selected_symbols:
                if not self.data[symbol].is_ready():
                    continue

                daily_returns:np.array = self.data[symbol].get_daily_returns()

                correlation:float = np.corrcoef(leveraged_daily_returns, daily_returns)[0][-1]

                # make sure correlation is greater than threshold
                abs_corr:float = abs(correlation)
                if abs_corr >= self.corr_threshold:
                    if (l_symbol not in abs_corr_by_symbol) or \
                        (l_symbol in abs_corr_by_symbol and abs_corr > abs_corr_by_symbol[l_symbol][1]):
                        # go short (long) on ETF, when correlation is negative (positive)
                        short_signal:bool = True if correlation < 0 else False
                        abs_corr_by_symbol[l_symbol] = (symbol, abs_corr, short_signal)

            # create trade pairs
            for l_symbol, pair_tuple in abs_corr_by_symbol.items():
                symbol:Symbol = pair_tuple[0]
                short_signal:bool = pair_tuple[2]

                symbols_to_trade.add(symbol)
                symbols_to_trade.add(l_symbol)
                
                trade_pairs.append(TradePair(symbol, l_symbol, short_signal))

        if len(trade_pairs) == 0:
            return Universe.Unchanged

        total_trades:int = len(trade_pairs) * 2
        portfolio_partition:float = self.Portfolio.TotalPortfolioValue / total_trades

        # calculate quantity for each ETF and LETF in trade pairs
        for trade_pair in trade_pairs:
            l_etf_price:float = self.data[trade_pair.l_etf_symbol].get_last_price()
            l_etf_leverage:float = self.l_etfs[trade_pair.l_etf_symbol.Value]
            etf_price:float = self.data[trade_pair.etf_symbol].get_last_price()

            l_etf_quantity:float = np.floor(portfolio_partition / l_etf_price / l_etf_leverage)
            etf_quantity:float = np.floor(portfolio_partition / etf_price)

            self.trades.append([trade_pair.l_etf_symbol, -l_etf_quantity])
            self.trades.append([trade_pair.etf_symbol, -etf_quantity if trade_pair.short_signal else etf_quantity])

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

        self.Liquidate()

        for symbol, quantity in self.trades:
            self.MarketOrder(symbol, quantity)

        self.trades.clear()
        
    def Selection(self) -> None:
        self.selection_flag = True

Leave a Reply

Discover more from Quant Buffet

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

Continue reading