“该策略涉及按行业对股票进行排序,使用CSSD衡量羊群效应,并利用动量对羊群效应较低的行业中排名前50%的行业建立多头头寸,对排名后50%的行业建立空头头寸。”

I. 策略概要

投资范围包括49个Fama和French行业。股票被分类到行业投资组合中,羊群效应通过股票回报的横截面标准差(CSSD)来衡量。CSSD被标准化,并识别出羊群效应较低的行业。动量使用过去六个月的回报作为赢家和输家的代理进行计算。在羊群效应较低的行业中,动量排名前50%的行业被做多,而排名后50%的行业被做空。该策略每月重新平衡,投资组合采用等权重。

II. 策略合理性

投资策略侧重于49个Fama和French行业,将股票分类到行业投资组合中。通过估算个股回报相对于行业平均回报的横截面标准差(CSSD)来计算羊群效应。CSSD被标准化以确定羊群效应水平,其中前30%被认定为低羊群效应行业。动量被计算为每个行业过去六个月的回报。对于低羊群效应的行业,该策略做多动量排名前50%的行业,做空排名后50%的行业。投资组合采用等权重,每月重新平衡。

III. 来源论文

Industry Herding and Momentum [点击查看论文]

<摘要>

关于羊群行为的理论模型预测,在不同假设下,羊群效应可能使价格偏离(或趋近)基本面,并降低(或提高)市场效率。在本文中,我们研究了行业层面的羊群效应和动量的联合作用。我们发现,当投资者羊群效应水平较低时,动量效应会增强。投资者中的羊群行为有助于资产价格趋向基本面,提高市场效率并降低动量效应。当羊群效应水平较低时,对赢家行业建立多头头寸,对输家行业建立空头头寸的交易策略可以产生显著的回报。

IV. 回测表现

年化回报14.43%
波动率30.7%
β值-0.076
夏普比率0.34
索提诺比率-0.284
最大回撤N/A
胜率50%

V. 完整的 Python 代码

from AlgorithmImports import *
import numpy as np
from typing import List, Dict, Tuple
class IndustryHerdingandMomentum(QCAlgorithm):
    
    def Initialize(self):
        self.SetStartDate(2003, 1, 1)
        self.SetCash(100000)
        self.period:int = 7 * 21
        self.leverage:int = 5
        self.min_share_price:int = 5
        self.min_stocks:int = 5
        # Daily price data.
        self.data:Dict[Symbol, SymbolData] = {}
        
        market:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
        
        self.long:List[Symbol] = []
        self.short:List[Symbol] = []
        
        # Historical industry CSSD data.
        self.industry_CSSD:Dict[str, float] = {}
        self.cssd_period:int = 3    # Minimum cssd data period.
        
        self.fundamental_count:int = 1000
        self.fundamental_sorting_key = lambda x: x.DollarVolume
        self.selection_flag:bool = True
        self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
        self.UniverseSettings.Resolution = Resolution.Daily
        self.AddUniverse(self.FundamentalSelectionFunction)
        self.Schedule.On(self.DateRules.MonthEnd(market), self.TimeRules.AfterMarketOpen(market), self.Selection)
        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]:
        # Update the rolling window every day.
        for stock in fundamental:
            symbol:Symbol = stock.Symbol
            # Store monthly price.
            if symbol in self.data:
                self.data[symbol].update(stock.AdjustedPrice)
        if not self.selection_flag:
            return Universe.Unchanged
        selected:List[Fundamental] = [
            x for x in fundamental if x.HasFundamentalData and x.Market == 'usa' and x.Price >= self.min_share_price and x.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]]
        industry_group:Dict[str, List[Symbol]] = {}
        # Warmup price rolling windows.
        for stock in selected:
            symbol:Symbol = stock.Symbol
            if symbol not in self.data:
                self.data[symbol] = SymbolData(self.period)
                history:dataframe = self.History(symbol, self.period, Resolution.Daily)
                if history.empty:
                    self.Log(f"Not enough data for {symbol} yet")
                    continue
                closes:Series = history.loc[symbol].close
                for time, close in closes.items():
                    self.data[symbol].update(close)
            if not self.data[symbol].is_ready():
                continue
    
            # Adding stocks in groups
            industry_group_code:str = stock.AssetClassification.MorningstarIndustryGroupCode
            
            if industry_group_code == 0: continue
            
            if not industry_group_code in industry_group:
                industry_group[industry_group_code] = []
            industry_group[industry_group_code].append(symbol)
        
        # CSSD calc.
        cssd_performance_data:Dict[str, Tuple[float]] = {}
        for group_code, group_symbols in industry_group.items():
            # Groups with at least 5 stocks, so CSSD is worth to calculate.
            if len(group_symbols) >= self.min_stocks:
                # Calculate CSSD.
                performance_1M:List[float] = []      # Last month's performance to calculate CSSD.
                performance_6M:List[float] = []      # 6M momentum.
                
                for symbol in group_symbols:
                    performances:Tuple[float] = self.data[symbol].performances()
                    performance_1M.append(performances[0]) # Last month performance
                    performance_6M.append(performances[1]) # First six months performance
                        
                if len(performance_1M) >= self.min_stocks and len(performance_1M) == len(performance_6M):
                    avg_return:float = np.mean(performance_1M)
                    cssd:float = sqrt( ( sum([(x-avg_return)**2 for x in performance_1M]) / (len(performance_1M)-1) ) )
                    
                    if group_code not in self.industry_CSSD:
                        self.industry_CSSD[group_code] = []
                    
                    if len(self.industry_CSSD[group_code]) >= self.cssd_period:
                        normalized_cssd:float = (cssd - np.mean(self.industry_CSSD[group_code])) / np.std(self.industry_CSSD[group_code])
                        avg_momentum:float = np.mean(performance_6M)  # Group average momentum for last 6 months. (skipped last one)
                        
                        cssd_performance_data[group_code] = (normalized_cssd, avg_momentum)
                        
                    self.industry_CSSD[group_code].append(cssd)
        
        if len(cssd_performance_data) != 0:
            sorted_by_cssd:List[Tuple[str, float]] = sorted(cssd_performance_data.items(), key = lambda x: x[1][0], reverse = True)
            count:int = int(len(sorted_by_cssd) * 0.3)
            low_herding:List[Tuple[str, float]] = [x for x in sorted_by_cssd[:count]]
            
            sorted_by_momentum:List[Tuple[str, float]] = sorted(low_herding, key = lambda x: x[1][1], reverse = True)
            count:int = int(len(sorted_by_momentum) * 0.5)
            long_groups:List[str] = [x[0] for x in sorted_by_momentum[:count]]
            short_groups:List[str] = [x[0] for x in sorted_by_momentum[-count:]]
            
            self.long:List[Symbol] = [symbol for x in long_groups for symbol in industry_group[x]]
            self.short:List[Symbol] = [symbol for x in short_groups for symbol in industry_group[x]]
        
        return self.long + self.short
                        
    def Selection(self) -> None:
        self.selection_flag = True
    
    def OnData(self, data: Slice) -> None:
        if not self.selection_flag:
            return
        self.selection_flag = False
        # Trade execution.
        targets:List[PortfolioTarget] = []
        for i, portfolio in enumerate([self.long, self.short]):
            for symbol in portfolio:
                if symbol in data and data[symbol]:
                    targets.append(PortfolioTarget(symbol, ((-1) ** i) / len(portfolio)))
        
        self.SetHoldings(targets, True)
        
        self.long.clear()
        self.short.clear()
        
class SymbolData():
    def __init__(self, period:int):
        self._monthly_closes:RollingWindow = RollingWindow[float](period)
        
    def update(self, close:float) -> None:
        self._monthly_closes.Add(close)
        
    def is_ready(self) -> bool:
        return self._monthly_closes.IsReady
        
    def performances(self) -> Tuple[float]:
        monthly_closes:List[float] = list(self._monthly_closes)
        last_month:List[float] = monthly_closes[:21]
        first_six_months:List[float] = monthly_closes[21:]
        
        last_month_performance:float = last_month[0] / last_month[-1] - 1
        first_months_performance:float = first_six_months[0] / first_six_months[-1] - 1
        
        return (last_month_performance, first_months_performance)
        
# 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 的更多信息

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

继续阅读