“该策略通过利用季节性反转交易纽约证券交易所、美国证券交易所和纳斯达克股票,做多低平均回报投资组合,做空高平均回报投资组合,每月进行价值加权重新平衡。”

I. 策略概要

该策略的目标是纽约证券交易所、美国证券交易所和纳斯达克的股票,不包括非普通股。在t月,通过根据过去20年的数据,按股票在t月以外的月份的平均回报将股票分为六个投资组合,构建季节性反转因子。该策略做多平均回报最低的两个投资组合,做空平均回报最高的两个投资组合。投资组合按价值加权,每月重新平衡,利用季节性回报模式捕捉反转机会。

II. 策略合理性

股票回报的季节性和预期回报的差异通过季节性反转来平衡,确保长期预期回报不受影响。当一只股票在特定月份表现优异时,其在其他月份的回报往往较低,从而满足累加约束。这表明季节性是由暂时性错误定价而非基于风险的因素引起的,因为风险溢价本质上不会累加为零。当交易者将价格暂时推高或推低至基本价值之上或之下时,就会发生错误定价,而反转会纠正这些偏差。季节性反转与短期反转、动量或长期反转不同,尽管与后者有一些相似之处,但驱动它们的机制不同。

III. 来源论文

Are Return Seasonalities Due to Risk or Mispricing? Evidence from Seasonal Reversals [点击查看论文]

<摘要>

股票每年在同一月份相对于其他股票而言,往往会获得较高或较低的回报(Heston和Sadka,2008)。我们表明,这些季节性由季节性反转平衡:一只股票在一个月内相对于其他股票具有较高的预期回报,则在其他月份相对于其他股票具有较低的预期回报。季节性和季节性反转在日历年内累加为零,这与季节性由暂时性错误定价驱动的观点一致。季节性反转在经济上规模较大,在统计上高度显著,并且它们与长期反转相似但不同。

IV. 回测表现

年化回报5.54%
波动率8.07%
β值0.022
夏普比率0.19
索提诺比率-0.09
最大回撤N/A
胜率49%

V. 完整的 Python 代码

from AlgorithmImports import *
import numpy as np
from typing import List, Dict, Tuple
#endregion
class TwelveMonthSeasonalReversals(QCAlgorithm):
    def Initialize(self) -> None:
        self.SetStartDate(2000, 1, 1)
        self.SetCash(100_000)
        self.exchange_codes: List[str] = ['NYS', 'NAS', 'ASE']
        market: Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
        
        # monthly prices
        self.data: Dict[Symbol, SymbolData] = {} 
        self.period: int = 5 * 12 + 1
        self.weight: Dict[Symbol, float] = {}
        self.min_share_price: int = 5
        self.quantile: int = 3
        self.leverage: int = 5
        self.fundamental_count: int = 1000
        self.fundamental_sorting_key = lambda x: x.DollarVolume
        self.selection_flag: bool = False
        self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
        self.UniverseSettings.Resolution = Resolution.Daily
        self.AddUniverse(self.FundamentalSelectionFunction)
        self.Schedule.On(self.DateRules.MonthStart(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]:
        if not self.selection_flag:
            return Universe.Unchanged
        # update the rolling window every month
        for stock in fundamental:
            symbol: Symbol = stock.Symbol
            # Store monthly price.
            if symbol in self.data:
                self.data[symbol].update(stock.AdjustedPrice)
        selected: List[Fundamental] = [
            x for x in fundamental if x.HasFundamentalData and x.Price > self.min_share_price and x.Market == 'usa' \
            and x.MarketCap > 0 and x.SecurityReference.ExchangeId in self.exchange_codes
        ]
        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 in self.data:
                continue
            
            self.data[symbol] = SymbolData(self.period)
            history: dataframe = self.History(symbol, self.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: int = 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.data[symbol].update(time_close[1])
        
        average_return: Dict[FindFundamental, float] = {}
        for stock in selected:
            if not self.data[symbol].is_ready():
                continue
            monthly_returns: np.ndarray = self.data[stock.Symbol].monthly_returns()
                
            # other-calendar-month returns
            relevant_monthly_returns:List[float] = [ret for i, ret in enumerate(monthly_returns) if i % 12 != 0 or i == 0]
            average_return[stock] = np.average(relevant_monthly_returns)
        
        if len(average_return) < self.quantile:
            return Universe.Unchanged
        # avg return sorting
        sorted_by_return: List[Tuple] = sorted(average_return.items(), key = lambda x: x[1], reverse = False)
        quantile: int = int(len(sorted_by_return) / self.quantile)
        short: List[Fundamental] = [x[0] for x in sorted_by_return[:quantile]]
        long: List[Fundamental] = [x[0] for x in sorted_by_return[-quantile:]]
        
        for i, portfolio in enumerate([long, short]):
            mc_sum:float = sum(list(map(lambda stock: stock.MarketCap, portfolio)))
            for stock in portfolio:
                self.weight[stock.Symbol] = ((-1)**i) * stock.MarketCap / mc_sum
        return list(self.weight.keys())
    def OnData(self, data: Slice) -> None:
        if not self.selection_flag:
            return
        self.selection_flag = False
        
        # trade execution
        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 Selection(self) -> None:
        self.selection_flag = True
class SymbolData():
    def __init__(self, period: int) -> None:
        self._monthly_prices: RollingWindow = RollingWindow[float](period)
            
    def update(self, price: float) -> None:
        self._monthly_prices.Add(price)
            
    def is_ready(self) -> bool:
        return self._monthly_prices.IsReady
        
    def monthly_returns(self) -> np.ndarray:
        monthly_closes: np.ndarray = np.array([x for x in self._monthly_prices])
        return (monthly_closes[:-1] / monthly_closes[1:] - 1)
# Custom fee model.
class CustomFeeModel(FeeModel):
    def GetOrderFee(self, parameters: OrderFeeParameters) -> OrderFee:
        fee: float = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
        return OrderFee(CashAmount(fee, "USD"))

发表评论

了解 Quant Buffet 的更多信息

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

继续阅读