投资范围包括纽交所、美国证券交易所和纳斯达克的股票,价格高于5美元。每月初,根据市值中位数分为两半,仅使用较大市值的股票。计算过去六个月的实际回报和年化波动率,并跳过月初前一周。根据回报和波动率将股票分为五分位,做多高回报、波动率最高组,做空低回报、波动率最高组。股票等权重分配,持有六个月,每月重新平衡1/6。

策略概述

投资范围包括纽交所、美国证券交易所和纳斯达克股票,价格高于每股5美元。每月初,根据市值中位数将样本分为两半,仅使用较大市值的股票。然后,每月计算每只股票过去六个月的实际回报和年化波动率。为了避免微观结构偏差,跳过月初前的一周(七天)。根据过去回报和波动率将股票分为五分位。投资者做多高回报、波动率最高组中的股票,并做空低回报、波动率最高组中的股票。股票等权重分配,持有六个月,因此每月重新平衡组合的1/6。

策略合理性

学术研究提出,中期动量效应大多通过行为路径进行解释。信息逐步扩散和/或投资者反应不足导致了动量效应(Chan、Jegadeesh 和 Lakonishok, 1996;Hong、Lim 和 Stein, 2000)。一些研究者显示,在信息不确定的情况下,回报延续性可能增强,假设投资者面对模糊信息时反应更为不足(由于过度自信)。按照这一思路,投资者在高信息不确定性的证券(如小市值股票和高波动率股票)中应看到更强的动量效应。

论文来源

Do Momentum and Reversals Coexist? [点击浏览原文]

<摘要>

标题问题的答案是“是的”。本研究分析了1964年至2009年间在纽交所、美国证券交易所和纳斯达克交易的股票,发现虽然小市值股票中普遍存在动量效应,但在持有期最长为六个月的时间段内,大市值股票中同时存在动量效应和反转效应。这种动量/反转的分界线沿着波动率维度:大市值/低波动率股票表现出反转,而大市值/高波动率股票则表现出动量。该发现无法完全用基于风险或行为的解释合理化。

回测表现

年化收益率16.46%
波动率19.22
Beta0.046
夏普比率0.354
索提诺比率0.374
最大回撤41.2%
胜率50%

完整python代码

import numpy as np
from AlgoLib import *

class MomentumReversalCombinedWithVolatilityEffectinStocks(XXX):

    def Initialize(self):
        self.SetStartDate(2010, 1, 1)
        self.SetCash(100000)

        market:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
        
        # EW Tranching.
        self.holding_period:int = 6
        self.managed_queue:List[RebalanceQueueItem] = []

        # Daily price data.
        self.data:Dict[Symbol, SymbolData] = {}
        self.period:int = 6 * 21
        self.leverage:int = 5
        self.min_share_price:float = 5.
        self.exchange_codes:List[str] = ['NYS', 'NAS', 'ASE']
        self.quantile:int = 5
        
        self.fundamental_count:int = 500
        self.fundamental_sorting_key = lambda x: x.DollarVolume

        self.selection_flag = False
        self.UniverseSettings.Resolution = Resolution.Daily
        self.AddUniverse(self.FundamentalSelectionFunction)
        self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
        self.Schedule.On(self.DateRules.MonthStart(market), self.TimeRules.AfterMarketOpen(market), 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]) -> None:
        # Update the rolling window every day.
        for stock in fundamental:
            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.AdjustedPrice > self.min_share_price and \
            x.SecurityReference.ExchangeId in self.exchange_codes 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]]

        sorted_by_market_cap:List[Fundamental] = sorted(selected, key = lambda x: x.MarketCap, reverse=True)
        half:int = int(len(sorted_by_market_cap) / 2)
        top_by_market_cap:List[Symbol] = [x.Symbol for x in sorted_by_market_cap][:half]

        perf_volatility:Dict[Symbol, Tuple[float, float]] = {}

        # Warmup price rolling windows.
        for stock in selected:
            symbol = stock.Symbol
            
            if symbol not in self.data:
                self.data[symbol] = SymbolData(symbol, self.period)
                history = self.History(symbol, self.period, Resolution.Daily)
                if history.empty:
                    self.Log(f"Not enough data for {symbol} yet.")
                    continue
                closes = history.loc[symbol].close
                for time, close in closes.items():
                    self.data[symbol].update(close)
            
            # Performance and volatility tuple.
            if self.data[symbol].is_ready():
                performance = self.data[symbol].performance()
                annualized_volatility = self.data[symbol].volatility()
                perf_volatility[symbol] = (performance, annualized_volatility)                
        
        long:List[Symbol] = []
        short:List[Symbol] = []
        if len(perf_volatility) >= self.quantile:
            sorted_by_perf:List[Tuple] = sorted(perf_volatility.items(), key = lambda x: x[1][0], reverse = True)
            quantile:int = int(len(sorted_by_perf) / self.quantile)
            top_by_perf:List[Symbol] = [x[0] for x in sorted_by_perf[:quantile]]
            low_by_perf:List[Symbol] = [x[0] for x in sorted_by_perf[-quantile:]]
            
            sorted_by_vol:List[Tuple] = sorted(perf_volatility.items(), key = lambda x: x[1][1], reverse = True)
            quantile = int(len(sorted_by_vol) / self.quantile)
            top_by_vol:List[Symbol] = [x[0] for x in sorted_by_vol[:quantile]]
            low_by_vol:List[Symbol] = [x[0] for x in sorted_by_vol[-quantile:]]
            
            long = [x for x in top_by_perf if x in top_by_vol]
            short = [x for x in low_by_perf if x in top_by_vol]

        if len(long) != 0:
            long_w:float = self.Portfolio.TotalPortfolioValue / self.holding_period / len(long)
            # symbol/quantity collection
            long_symbol_q:List = [(x, np.ceil(long_w / self.data[x].get_last_price())) for x in long]
        else:
            long_symbol_q:List = []
    
        if len(short) != 0:
            short_w:float = self.Portfolio.TotalPortfolioValue / self.holding_period / len(short)
            # symbol/quantity collection
            short_symbol_q:List = [(x, -np.ceil(short_w / self.data[x].get_last_price())) for x in short]
        else:
            short_symbol_q:List = []
                
        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 = None
        
        # Rebalance portfolio
        for item in self.managed_queue:
            if item.holding_period == self.holding_period:
                for symbol, quantity in item.symbol_q:
                        self.MarketOrder(symbol, -quantity)
                
                remove_item = item
            
            # Trade execution    
            if item.holding_period == 0:
                open_symbol_q = []
                
                for symbol, quantity in item.symbol_q:
                    if symbol in data and data[symbol] and self.Securities[symbol].IsTradable:
                        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:List):
        # symbol/quantity collections
        self.symbol_q:List = symbol_q  
        self.holding_period:int = 0

class SymbolData():
    def __init__(self, symbol: Symbol, period: int):
        self._symbol:Symbol = symbol
        self._price:RollingWindow = RollingWindow[float](period)
        self._last_price:float = 0
    
    def update(self, price: float) -> None:
        self._price.Add(price)
        self._last_price:float = price
    
    def get_last_price(self) -> float:
        return self._last_price
    
    def is_ready(self) -> bool:
        return self._price.IsReady
    
    def volatility(self) -> float:
        closes:np.ndarray = np.array(list(self._price)[5:]) # Skip last week.
        daily_returns:np.ndarray = closes[:-1] / closes[1:] - 1
        return np.std(daily_returns) * np.sqrt(252 / (len(closes)))
        
    def performance(self) -> float:
        closes:List[float] = list(self._price)[5:] # Skip last week.
        return (closes[0] / closes[-1] - 1)

# 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