投资范围涵盖NYSE、AMEX和NASDAQ的所有股票,数据来自CRSP数据库。每个月底,投资者计算每只股票的当前价格与其52周高点的比率,并求出各行业的加权平均值。赢家为比率最高的六个行业的股票,输家为最低的六个行业。投资者买入赢家组合,做空输家组合,持有三个月。股票按等权重分配,投资组合每月再平衡。

策略概述

投资范围涵盖NYSE、AMEX和NASDAQ的所有股票(研究论文使用CRSP数据库进行回测)。每个月底计算每只股票的当前价格与52周高点之间的比率(PRILAG i,t = Price i,t / 52周高点 i,t)。每月,投资者计算每个行业所有公司比率(PRILAG i,t)的加权平均值(使用20个行业),其中权重为股票在当月月底的市值。赢家(输家)为比率加权平均值最高(最低)的六个行业中的股票。投资者买入赢家组合中的股票,做空输家组合中的股票,并持有三个月。股票为等权重分配,投资组合每月再平衡(意味着每个月再平衡组合的1/3)。

策略合理性

学术界推测这种效应与“调整和锚定偏差”有关。锚定是一种心理偏差,意味着人们从隐含的参考点开始(锚点 -> 本例中的52周高点),然后基于额外信息逐步进行调整。金融论文指出,交易者使用52周高点作为参考点来评估新闻的潜在影响。当利好消息将某只股票的价格推高至接近或达到新的52周高点时,即使信息表明价格应继续上涨,交易者也不愿意进一步出价。信息最终会起作用,价格上升,从而导致持续上涨。对于52周低点,作用机制相似。

论文来源

Industry Information and the 52-Week High Effect [点击浏览原文]

<摘要>

我们发现52周高点效应(George和Hwang,2004年)无法用风险因素解释。相反,它更符合投资者锚定偏差导致的反应不足:假设较为成熟的机构投资者较少受到这种偏差的影响,并会购买(卖出)接近(远离)52周高点的股票。此外,这一效应主要由投资者对行业信息的反应不足驱动,而非公司特定的信息。对于正面行业信息,反应不足的程度比负面信息更大。从1963年到2009年,买入价格接近52周高点的行业股票并卖出远离52周高点的行业股票的策略,月收益率为0.60%,比同期个人52周高点策略的利润高出约50%。52周高点策略在高R平方值和高行业贝塔值的股票中效果最好(即股票价值更受行业因素影响,较少受公司特定信息影响)。此外,我们的行业52周高点效应在价格信息较少的公司中更为明显,正是投资者更有可能受到锚定偏差影响的公司类型。即使控制了个股和行业的动量效应,我们的结果依然成立。

回测表现

年化收益率11.75%
波动率11%
Beta-0.409
夏普比率-0.014
索提诺比率-0.015
最大回撤58.2%
胜率53%

完整python代码

from numpy import floor
from AlgoLib import *
from typing import List, Dict, Tuple

class Weeks52HighEffectinStocks(XXX):

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

        self.SetSecurityInitializer(lambda x: x.SetMarketPrice(self.GetLastKnownPrice(x)))
        
        self.period:int = 12 * 21

        # Tranching.
        self.holding_period:int = 3
        self.managed_queue:List[RebalanceQueueItem] = []
        self.industry_count:int = 6
        self.leverage:int = 5
        self.selection_sorting_key = lambda x:x.MarketCap

        # Daily 'high' data.
        self.data:Dict[Symbol, SymbolData] = {}
        
        self.symbol:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
        
        self.fundamental_count:int = 500
        self.selection_flag:bool = False
        self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
        self.UniverseSettings.Resolution = Resolution.Daily
        self.AddUniverse(self.FundamentalSelectionFunction)
        self.Schedule.On(self.DateRules.MonthEnd(self.symbol), self.TimeRules.AfterMarketOpen(self.symbol), 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]:
        # Update the rolling window every day.
        for stock in fundamental:
            symbol:Symbol = stock.Symbol

            if symbol in self.data:
                # Store daily price.
                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.MarketCap != 0 and \
                                    ((x.SecurityReference.ExchangeId == "NYS") or (x.SecurityReference.ExchangeId == "NAS") or (x.SecurityReference.ExchangeId == "ASE"))]

        if len(selected) > self.fundamental_count:
            selected = [x for x in sorted(selected, key=self.selection_sorting_key, reverse=True)[:self.fundamental_count]]

        group:Dict[MorningstarIndustryGroupCode, float] = {}

        # Warmup price rolling windows.
        for stock in selected:
            symbol:Symbol = stock.Symbol
            if symbol not in self.data:
                self.data[symbol] = SymbolData(symbol, 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:pd.Series = history.loc[symbol].close
                for time, close in closes.items():
                    self.data[symbol].update(close)
                
            if not self.data[symbol].is_ready():
                continue

            industry_group_code:MorningstarIndustryGroupCode = stock.AssetClassification.MorningstarIndustryGroupCode
            if industry_group_code == 0: continue
            
            # Adding stocks in groups.
            if industry_group_code not in group:
                group[industry_group_code] = []
            
            max_high:float = self.data[symbol].maximum()
            price:float = self.data[symbol].get_latest_price()
            
            stock_prilag:float = (stock, price / max_high)
            group[industry_group_code].append(stock_prilag)
        
        top_industries:List[MorningstarIndustryGroupCode] = []
        low_industries:List[MorningstarIndustryGroupCode] = []
        
        if len(group) != 0: 
            # Weighted average of ratios calc.
            industry_prilag_weighted_avg:Dict[int, float] = {}
            for industry_code in group:
                total_market_cap:float = sum([stock_prilag_data[0].MarketCap for stock_prilag_data in group[industry_code]])
                if total_market_cap == 0: continue
                industry_prilag_weighted_avg[industry_code] = sum([stock_prilag_data[1] * (stock_prilag_data[0].MarketCap / total_market_cap) for stock_prilag_data in group[industry_code]])
            
            if len(industry_prilag_weighted_avg) != 0:
                # Weighted average industry sorting.
                sorted_by_weighted_avg:List = sorted(industry_prilag_weighted_avg.items(), key=lambda x: x[1], reverse = True)
                top_industries = [x[0] for x in sorted_by_weighted_avg[:self.industry_count]]
                low_industries = [x[0] for x in sorted_by_weighted_avg[-self.industry_count:]]
        
        long:List[Symbol] = []
        short:List[Symbol] = []
        for industry_code in top_industries:
            for stock_prilag_data in group[industry_code]:
                symbol:Symbol = stock_prilag_data[0].Symbol
                long.append(symbol)
        
        for industry_code in low_industries:
            for stock_prilag_data in group[industry_code]:
                symbol:Symbol = stock_prilag_data[0].Symbol
                short.append(symbol)
                
        long_w:float = self.Portfolio.TotalPortfolioValue / self.holding_period / len(long)
        short_w:float = self.Portfolio.TotalPortfolioValue / self.holding_period / len(short)
        
        # symbol/quantity collection
        long_symbol_q:List[Tuple[Union[Symbol, int]]] = [(x, floor(long_w / self.data[x].get_latest_price())) for x in long]
        short_symbol_q:List[Tuple[Union[Symbol, int]]] = [(x, -floor(short_w / self.data[x].get_latest_price())) for x in short]
        
        self.managed_queue.append(RebalanceQueueItem(long_symbol_q + short_symbol_q))
        
        return long + short

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

        remove_item:int = None
        
        # Rebalance portfolio
        for item in self.managed_queue:
            if item.holding_period == self.holding_period:
                # Liquidate
                for symbol, quantity in item.symbol_q:
                    self.MarketOrder(symbol, -quantity)
                remove_item = item
            
            # Trade execution    
            if item.holding_period == 0:
                open_symbol_q:List[Tuple[Symbol, int]] = []
                
                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)

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

class RebalanceQueueItem():
    def __init__(self, symbol_q:Tuple[Symbol, int]) -> None:
        # symbol/quantity collections
        self.symbol_q:Tuple[Symbol, int] = symbol_q  
        self.holding_period:int = 0

class SymbolData():
    def __init__(self, symbol:Symbol, period:int) -> None:
        self.Symbol:Symbol = symbol
        self.Price:RollingWindow = RollingWindow[float](period)
    
    def update(self, value:float) -> None:
        self.Price.Add(value)
    
    def is_ready(self) -> bool:
        return self.Price.IsReady
     
    def maximum(self) -> float:
        return max([x for x in self.Price])
        
    def get_latest_price(self) -> float:
        return [x for x in self.Price][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"))

Leave a Reply

Discover more from Quant Buffet

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

Continue reading