“该策略利用杠杆ETF资金流创建情绪指数(SSI),根据对SSI的敏感性交易纽约证券交易所股票,头寸规模按SSI幅度调整,并每月重新平衡。”

I. 策略概要

该策略利用六种杠杆ETF(三只做多ETF:QLD、SSO、DDM;三只做空ETF:QID、SDS、DXD)构建投机情绪指数(SSI),并以此交易纽约证券交易所股票。SSI的计算方法是:评估ETF的股票百分比变化,计算做多和做空ETF之间的净资金流,并将此净资金流建模为AR(1)过程。AR(1)过程的残差构成SSI。

对于每只纽约证券交易所股票,其对滞后SSI的敏感性通过滚动36个月回归进行估计。然后,每月根据SSI敏感性将股票分为五分位数。该策略仅使用第一(低敏感性)和第五(高敏感性)五分位数构建多空投资组合。当SSI为正时,做多第五五分位数,做空第一五分位数;当SSI为负时,头寸反转。投资组合的敞口随SSI绝对值而调整——SSI越高,杠杆越高;SSI越低,敞口越小,现金持有量越大。投资组合按价值加权,每月重新平衡。这种方法利用杠杆ETF资金流中反映的情绪驱动市场动态。

II. 策略合理性

杠杆ETF主要被投机性交易者使用,因为它们适合短期目标、具有高昂的直接和间接成本、机构持股比例较低以及与非杠杆ETF相比交易量大。杠杆ETF是为投机目的而设计的利基产品。投机情绪指数(SSI)与现有情绪指标不同,除了市场隐含波动率之外,它们之间的相关性很低,但市场隐含波动率具有统计显著的相关性。与大多数只衡量幅度而不衡量方向的情绪指标不同,SSI同时捕捉了这两个方面。正的SSI值表明投机性交易者正在大量买入做多股票敞口,而负值则表明强劲的做空股票敞口。SSI反映了暂时影响资产价格的非基本面需求冲击,导致回报反转。这些反转构成了盈利策略的基础——逆投机情绪而行。

III. 来源论文

Speculation Sentiment [点击查看论文]

<摘要>

我利用杠杆交易型开放式指数基金(ETF)的一级市场来衡量总体的、不知情的、赌博式的需求,即投机情绪。杠杆ETF一级市场是一个新颖的环境,它提供了可观察到的套利活动,这些活动归因于纠正ETF份额与其标的资产之间的错误定价。套利活动代表了投机需求冲击的幅度和方向,我用它来构建投机情绪指数。该指标与同期市场回报呈负相关(例如,在下跌市场中看涨),并负向预测回报。结果与投机情绪导致市场范围内的价格扭曲随后反转一致。

IV. 回测表现

年化回报12.28%
波动率19.49%
β值-0.001
夏普比率0.63
索提诺比率-0.011
最大回撤N/A
胜率50%

V. 完整的 Python 代码

from AlgorithmImports import *
import statsmodels.api as sm
from scipy import stats
# endregion
class TradingBasedonLeveredETFsSpeculationSentiment(QCAlgorithm):
    def Initialize(self):
        self.SetStartDate(2000, 1, 1)
        self.SetCash(100_000)
        self.long_etfs: List[str] = ['QLD', 'SSO', 'DDM']
        self.short_etfs: List[str] = ['QID', 'SDS', 'DXD']
        
        # previous month's SO
        self.previous_so: Dict[str, float] = {
            ticker : 0 for ticker in self.long_etfs + self.short_etfs
        }
        
        # monthly prices
        self.m_price_data: Dict[Symbol, RollingWindow] = {}
        # market data
        self.m_regression_period: int = 36 + 1
        self.market: Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
        self.m_price_data[self.market]: RollingWindow = RollingWindow[float](self.m_regression_period)
        
        self.net_percent_share_change_series: RollingWindow = RollingWindow[float](self.m_regression_period)
        self.shares_out_data:Symbol = self.AddData(ETFSharesOutstanding, 'ETFSharesOutstanding', Resolution.Daily).Symbol
        self.weight: Dict[Symbol, float] = {}   # traded weight for a given month
        # universe selection
        self.quantile: int = 5
        self.leverage: int = 5
        self.leverage_cap: int = 5
        self.exchange_codes: List[str] = ['NYS']
        
        self.fundamental_count: int = 500
        self.fundamental_sorting_key = lambda x: x.DollarVolume
        self.recent_month: int = -1
        self.rebalance_flag: bool = False
        self.selection_flag: bool = False
        self.UniverseSettings.Resolution = Resolution.Daily
        self.AddUniverse(self.FundamentalSelectionFunction)
        self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
        self.settings.daily_precise_end_time = False
    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]:
        if not self.selection_flag:
            return Universe.Unchanged
        # update rolling windows every month
        for stock in fundamental:
            symbol: Symbol = stock.Symbol
            if symbol in self.m_price_data:
                self.m_price_data[symbol].Add(stock.AdjustedPrice)
        self.selection_flag = False
        
        selected: List[Fundamental] = [
            f for f in fundamental if f.HasFundamentalData 
            and f.Market == 'usa'
            and f.SecurityReference.ExchangeId in self.exchange_codes
            and f.MarketCap != 0
        ]
        if len(selected) > self.fundamental_count:
            selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]
            
        # warmup price rolling windows
        for stock in selected:
            symbol: Symbol = stock.Symbol
            if symbol not in self.m_price_data:
                self.m_price_data[symbol] = RollingWindow[float](self.m_regression_period)
                history: dataframe = self.History(symbol, self.m_regression_period * 30, Resolution.Daily)
                if history.empty:
                    self.Log(f"Not enough data for {symbol} yet.")
                    continue
                
                closes: Series = history.loc[symbol].close
                closes_len = len(closes.keys())
                # find monthly closes
                for index, time_close in enumerate(closes.items()):
                    # index out of bounds check.
                    if index + 1 < closes_len:
                        date_month: int = time_close[0].date().month
                        next_date_month: int = closes.keys()[index + 1].month
                    
                        # found last day of month
                        if date_month != next_date_month:
                            self.m_price_data[symbol].Add(time_close[1])
        if not self.net_percent_share_change_series.IsReady:
            return Universe.Unchanged
        # forming the SSI index
        # at this point, both market price data and net share change series data are ready
        market_prices: np.ndarray = np.array([x for x in self.m_price_data[self.market]])
        market_returns: np.ndarray = market_prices[:-1] / market_prices[1:] - 1
        share_changes: np.ndarray = np.array([x for x in self.net_percent_share_change_series])[:-1]
        ols_model = self.multiple_linear_regression(share_changes, market_returns)
        
        # time series of residuals form the Speculation Sentiment Index
        SSI: np.ndarray = ols_model.resid
        SSI_sensitivity: Dict[Fundamental, float] = {}
        self.daily_return: dataframe = pd.dataframe()
        for stock in selected :
            symbol: Symbol = stock.Symbol
            if self.m_price_data[symbol].IsReady:
                # at this point, both stock price data and net share change series data are ready
                stock_prices: np.ndarray = np.array([x for x in self.m_price_data[symbol]])
                stock_returns: np.ndarray = stock_prices[:-1] / stock_prices[1:] - 1
                self.daily_return[stock] = stock_returns
                # estimate monthly sensitivity to lagged SSI
                ols_model = self.multiple_linear_regression(SSI[1:], stock_returns[:-1])
                SSI_sensitivity[stock] = ols_model.params[1]
        
        if len(SSI_sensitivity) >= self.quantile:
            # sorting by SSI sensitivity
            sorted_by_sensitivity: List[Fundamental] = sorted(SSI_sensitivity, key = SSI_sensitivity.get, reverse=True)
            quantile: int = int(len(sorted_by_sensitivity) / self.quantile)
            
            long: List[Fundamental] = []
            short: List[Fundamental] = []
            if SSI[0] > 0:
                long = sorted_by_sensitivity[-quantile:]
                short = sorted_by_sensitivity[:quantile]
            else:
                long = sorted_by_sensitivity[:quantile]
                short = sorted_by_sensitivity[-quantile:]
            # value weighting
            for i, portfolio in enumerate([long, short]):
                mc_sum: float = sum(map(lambda x: x.MarketCap, portfolio))
                for stock in portfolio:
                    self.weight[stock.Symbol] = ((-1) ** i) * stock.MarketCap / mc_sum
            # magnitude of SSI scales the portfolio itself; average leverage is equal to zero
            abs_ssi_values: np.ndarray = abs(SSI)
            percentile: float = stats.percentileofscore(abs_ssi_values[1:], abs_ssi_values[0]) / 100.
            leverage: float = 1 - ((0.5 - percentile) * 2) if percentile < 0.5 else 1 + ((percentile - 0.5) * 2)
            self.weight = {
                x : w * leverage for x, w in self.weight.items()
            }
            
            self.rebalance_flag = True
        return list(self.weight.keys())
    def OnData(self, data: Slice) -> None:
        if (self.Time.date() > ETFSharesOutstanding.get_last_update_date()):
            self.selection_flag = False
            self.Liquidate()
            return
        # SO data came in 
        if self.shares_out_data in data and data[self.shares_out_data]:
            # universe selection and rebalance is done once a month when new SO data comes
            if self.Time.month != self.recent_month:
                self.selection_flag = True
                self.recent_month = self.Time.month
                long_psc_sum: float = 0
                short_psc_sum: float = 0
                data_ready_ticker_cnt: int = 0    # n of tickers with SO data ready
                for ticker in self.long_etfs + self.short_etfs:
                    so: float = data[self.shares_out_data].GetProperty(ticker)
                    
                    # ticker has previous month's SO data
                    if self.previous_so[ticker] != 0:
                        data_ready_ticker_cnt += 1
                        percent_share_change: float = -1 + (so / self.previous_so[ticker])
                        if ticker in self.long_etfs:
                            long_psc_sum += percent_share_change
                        else:
                            short_psc_sum += percent_share_change
                    
                    self.previous_so[ticker] = so
                
                # every ticker had SO data ready
                if data_ready_ticker_cnt == len(self.long_etfs + self.short_etfs):
                    psc_net: float = long_psc_sum - short_psc_sum
                    self.net_percent_share_change_series.Add(psc_net)
                    
        # rebalance once a month
        if not self.rebalance_flag:
            return
        self.rebalance_flag = False
        portfolio: List[PortfolioTarget] = [PortfolioTarget(symbol, w) for symbol, w in self.weight.items() if symbol in data and data[symbol]]
        self.SetHoldings(portfolio, True)
        self.weight.clear()
    
    def multiple_linear_regression(self, x:np.ndarray, y:np.ndarray, add_residual:bool = True):
        x:np.ndarray = np.array(x).T
        
        if add_residual:
            x = sm.add_constant(x)
        result = sm.OLS(endog=y, exog=x).fit()
        return result
# Custom data
class ETFSharesOutstanding(PythonData):
    _last_update_date: datetime.date = datetime(1,1,1).date()
    @staticmethod
    def get_last_update_date() -> datetime.date:
       return ETFSharesOutstanding._last_update_date
    def GetSource(self, config, date, isLiveMode):
        return SubscriptionDataSource("data.quantpedia.com/backtesting_data/equity/etf_shares_outstanding.csv", SubscriptionTransportMedium.RemoteFile, FileFormat.Csv)
    def Reader(self, config, line, date, isLiveMode):
        data = ETFSharesOutstanding()
        data.Symbol = config.Symbol
        
        if not line[0].isdigit():
            self.header_columns = line.split(';')
            return None
            
        split = line.split(';')
        data.Time = datetime.strptime(split[0], "%Y-%m-%d") + timedelta(days=1)
        data.Value = float(split[1])
        for i, col in enumerate(self.header_columns[1:]): # skip date
            data[col] = float(split[i+1])
        # store last update date
        if data.Time.date() > ETFSharesOutstanding._last_update_date:
            ETFSharesOutstanding._last_update_date = data.Time.date()
        return data
# Custom fee model.
class CustomFeeModel():
    def GetOrderFee(self, parameters):
        fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
        return OrderFee(CashAmount(fee, "USD"))

发表评论

了解 Quant Buffet 的更多信息

立即订阅以继续阅读并访问完整档案。

继续阅读