“该策略识别成交量飙升,构建一个在负回报期间贝塔值最低的10%ETF成分股的投资组合。这些股票被买入并持有40天,等权重。”

I. 策略概要

投资范围包括来自9个行业ETF、标普500 ETF和小盘股ETF的股票。该策略识别成交量飙升,定义为成交量高于平均值三个标准差的日子。如果成交量飙升伴随着负回报,投资者会创建一个等权重的投资组合,其中包含相对于ETF贝塔值最低的10%ETF成分股。这些股票被买入并持有40天。投资组合以等权重重新平衡。

II. 策略合理性

ETF中的扭曲和反转之所以发生,是因为并非所有成分股都以相同的方式或程度受到外部冲击的影响。这些“局外人”可能会因冲击的性质而异,冲击的性质可能包括商品价格下跌、负面盈利意外或政治事件等因素。成分股之间的差异越大,出现扭曲的机会就越多。这些不同的敞口导致潜在的市场低效,可以通过有针对性的投资策略加以利用。

III. 来源论文

The Revenge of the Stock Pickers [点击查看论文]

<摘要>

当交易所交易基金(ETF)围绕某个主题进行大量交易时,其成分股之间的相关性会显著增加。即使是一些对主题本身几乎没有或负面敞口的证券,也开始与其他ETF成分股同步交易。换句话说,由于ETF投资者对证券层面的信息不敏感,他们经常“鱼龙混杂”。随着个股价格随ETF被拉高或拉低,这些错误定价可能会变得显著,而利用它们实现的利润可能会为选股者提供机会。

IV. 回测表现

年化回报18%
波动率N/A
β值0.008
夏普比率N/A
索提诺比率-4.171
最大回撤N/A
胜率57%

V. 完整的 Python 代码

from AlgorithmImports import *
from QC100UniverseSelectionModel import QC100UniverseSelectionModel
from collections import deque
from scipy import stats
class StockPickingETFConstituents(QCAlgorithm):
    def Initialize(self):
        self.SetStartDate(2000, 1, 1)
        self.SetCash(100000)
        self.period:int = 21
        self.SetWarmup(self.period, Resolution.Daily)
        
        # Source: https://github.com/QuantConnect/Lean/blob/master/Algorithm.Framework/Selection/QC500UniverseSelectionModel.py
        self.UniverseSettings.Resolution = Resolution.Daily
        self.SetUniverseSelection(QC100UniverseSelectionModel(n_of_symbols = 100, select_every_n_months = 3))
        self.quantile:int = 10
        # daily price data
        self.data:dict[Symbol, deque] = {}
        self.market:Symbol = self.AddEquity('OEF', Resolution.Daily).Symbol
        
        self.day_holding_period:int = 40
        self.managed_queue:list[RebalanceQueueItem] = []
    def OnSecuritiesChanged(self, changes):
        # newly added proxy S&P stocks
        for security in changes.AddedSecurities:
            symbol:Symbol = security.Symbol
            
            security.SetFeeModel(CustomFeeModel())
            security.SetLeverage(10)
            if symbol not in self.data:
                self.data[symbol] = deque(maxlen = self.period)
        
        # delete removed S&P stock from data storage
        for security in changes.RemovedSecurities:
            symbol:Symbol = security.Symbol
            if symbol in self.data:
                del self.data[symbol]
    def OnData(self, data) -> None:
        # store daily data for universe
        for symbol in self.data:
            if symbol in data and data[symbol]:
                price:float = data[symbol].Value
                volume:float = data[symbol].Volume
                self.data[symbol].append((price, volume))
        market_closes:list[float] = []
        trade_flag:bool = False
        
        # market etf data is ready
        if self.market in self.data and len(self.data[self.market]) == self.data[self.market].maxlen:
            market_closes = [x[0] for x in self.data[self.market]]
            volumes:list[float] = [x[1] for x in self.data[self.market]]
            volume_mean:float = np.mean(volumes)
            volume_std:float = np.std(volumes)
            
            recent_volume:float = volumes[-1]
            
            # volume spike has not occured
            if recent_volume > volume_mean + 3 * volume_std:
                # last day's return was negative
                last_day_return:float = market_closes[-1] / market_closes[-2] - 1
                if last_day_return < 0:
                    trade_flag = True
        
        market_closes:np.ndarray = np.array(market_closes)
        
        if trade_flag:
            stock_beta:dict[Symbol, float] = {}
            for symbol in self.data:
                if symbol == self.market: continue
                # stock data is ready
                if (symbol in self.data and len(self.data[symbol]) == self.data[symbol].maxlen):
                    # beta calculation
                    stock_closes:np.ndarray = np.array([x[0] for x in self.data[symbol]])
                        
                    market_returns:np.ndarray = (market_closes[1:] - market_closes[:-1]) / market_closes[:-1]
                    stock_returns:np.ndarray = (stock_closes[1:] - stock_closes[:-1]) / stock_closes[:-1]
                    
                    # manual beta calc
                    cov = np.cov(market_returns, stock_returns)[0][1]
                    market_variance = np.std(market_returns) ** 2
                    beta = cov / market_variance            
                    
                    # beta, alpha, r_value, p_value, std_err = stats.linregress(market_returns, stock_returns)
                    stock_beta[symbol] = beta
            
            if len(stock_beta) >= self.quantile:
                # beta sorting
                sorted_by_beta:list = sorted(stock_beta.items(), key = lambda x: x[1], reverse = True)
                quantile:int = int(len(sorted_by_beta) / self.quantile)
                long:list[Symbol] = [x[0] for x in sorted_by_beta[-quantile:]]
                
                long_w:float = self.Portfolio.TotalPortfolioValue / self.day_holding_period / len(long)
                long_symbol_q:list[tuple[Symbol, float]] = [(x, np.floor(long_w / self.data[x][-1][0])) for x in long]
                
                # append long portfolio to managed queue
                self.managed_queue.append(RebalanceQueueItem(long_symbol_q))
        
        # rebalance portfolio
        remove_item:RebalanceQueueItem = None
        for item in self.managed_queue:
            if item.holding_period == self.day_holding_period:
                for symbol, quantity in item.symbol_q:
                    self.MarketOrder(symbol, -quantity)
                            
                remove_item = item
                
            elif item.holding_period == 0:
                open_symbol_q:list[tuple[Symbol, float]] = []
                
                for symbol, quantity in item.symbol_q:
                    if symbol in data and data[symbol]:
                        self.MarketOrder(symbol, quantity)
                        open_symbol_q.append((symbol, quantity))
                            
                # only opened orders will be closed
                item.symbol_q = open_symbol_q
                
            item.holding_period += 1
            
        # we need to remove closed part of portfolio after loop. Otherwise it will miss one item in self.managed_queue
        if remove_item:
            self.managed_queue.remove(remove_item)
class RebalanceQueueItem():
    def __init__(self, symbol_q:list) -> None:
        # symbol/quantity collections
        self.symbol_q:list[tuple[Symbol, float]] = symbol_q  
        self.holding_period:int = 0
        
# custom fee model
class CustomFeeModel(FeeModel):
    def GetOrderFee(self, parameters):
        fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
        return OrderFee(CashAmount(fee, "USD"))

发表评论

了解 Quant Buffet 的更多信息

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

继续阅读