“该策略涉及根据销售季节性投资于美国非金融股票。股票每月被分配到十个等级,做多最低的等级,做空最高的等级,并每月进行再平衡。”

I. 策略概要

该策略的投资范围包括在纽约证券交易所、美国证券交易所或纳斯达克上市的非金融类美国股票,不包括普通股。投资者使用SEA变量衡量销售季节性,该变量代表季度销售额除以年度销售额。为避免异常值,AVGSEA变量计算为前两年SEA的平均值。股票每月根据AVGSEA分为十个等级,做多最低等级的股票,做空最高等级的股票。该策略采用价值加权,持仓一个月,并每月进行再平衡。这种方法旨在从销售季节性中获取利润,并根据过往数据进行再平衡。

II. 策略合理性

该研究表明,销售和盈利异常在股票回报中并存,有证据表明高销售季节性公司与高资产增长公司相关。然而,溢价主要来自低销售季节性公司。投资者在低销售季度往往对股票关注较少,这是异常现象的核心原因。即使在控制了Fama-French的五个因子或动量等因素后,研究结果依然稳健。此外,即使在调整了价值加权投资组合、剔除了微市值股票并获得了高t统计量后,销售季节性溢价仍然显著。这表明该异常现象未被其他知名因子捕获,并且仍然是股票回报的强有力预测因子。

III. 来源论文

When Low Beats High: Riding the Sales Seasonality Premium [点击查看论文]

<摘要>

我们证明,根据销售季节性对股票进行排序可以预测未来异常回报。买入低销售季节性股票并卖出高销售季节性股票的多空策略产生了8.4%的年化阿尔法。此外,该策略随着时间的推移变得更强,在过去十年中产生了约15%的年化阿尔法。这种季节性效应在横截面回归中预测未来股票回报,并且独立于先前记录的季节性异常。此外,该交易策略的阿尔法不能用股票市场流动性、系统性风险、信息不对称或融资决策的差异来解释。进一步的测试表明,这种现象可能部分是由投资者关注水平的季节性波动驱动的。

IV. 回测表现

年化回报8.73%
波动率12.36%
β值0.331
夏普比率0.38
索提诺比率0.238
最大回撤N/A
胜率53%

V. 完整的 Python 代码

import numpy as np
from AlgorithmImports import *
from collections import deque
from typing import List, Dict, Tuple, Deque
from numpy import isnan
class SalesSeasonalityPremium(QCAlgorithm):
    def Initialize(self):
        self.SetStartDate(2000, 1, 1)
        self.SetCash(100000)
        self.avg_SEA_threshold:int = 100
        self.max_period:int = 1000
        self.period:int = 13
        self.leverage:int = 10
        self.min_share_price:int = 5
        self.consecutive_quarter_count:int = 6
        self.percentiles:List[int] = [10, 90]
        self.exchange_codes:List[str] = ['NYS', 'NAS', 'ASE']	
        self.sea:Dict[Symbol, Deque[Tuple[float]]] = {} # SEA quarterly data
        self.avgsea_alltime:Deque[float] = deque(maxlen=self.max_period)   # All time SEA values.
        market:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
        
        self.weight:Dict[Symbol, float] = {}
        
        self.fundamental_count:int = 3000
        self.fundamental_sorting_key = lambda x: x.MarketCap
        self.selection_flag:bool = True
        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
        selected:List[Fundamental] = [
            x for x in fundamental if x.HasFundamentalData and x.Price > self.min_share_price and x.Market == 'usa' and \
            not isnan(x.FinancialStatements.IncomeStatement.TotalRevenue.ThreeMonths) and x.FinancialStatements.IncomeStatement.TotalRevenue.ThreeMonths != 0 and \
            not isnan(x.FinancialStatements.IncomeStatement.TotalRevenue.TwelveMonths) and x.FinancialStatements.IncomeStatement.TotalRevenue.TwelveMonths != 0 and \
            not isnan(x.EarningReports.BasicAverageShares.ThreeMonths) and x.EarningReports.BasicAverageShares.ThreeMonths > 0 and \
            not isnan(x.EarningReports.BasicEPS.TwelveMonths) and x.EarningReports.BasicEPS.TwelveMonths > 0 and \
            not isnan(x.ValuationRatios.PERatio) and x.ValuationRatios.PERatio > 0 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]]
            
        # Stock which had earnings last month.
        last_month_date:datetime = self.Time - timedelta(days = self.Time.day)
        fine_selected = [x for x in selected if x.EarningReports.FileDate.Value.month == last_month_date.month and x.EarningReports.FileDate.Value.year == last_month_date.year]
        
        avg_sea:Dict[Fundamental, float] = {}
        for stock in fine_selected:
            symbol = stock.Symbol
            
            # store sea data.
            if symbol not in self.sea:
                self.sea[symbol] = deque(maxlen = self.period)
            # SEA calc.
            curr_month:float = stock.EarningReports.FileDate.Value.month
            revenue:float = stock.FinancialStatements.IncomeStatement.TotalRevenue.ThreeMonths
            # annual_revenue = stock.FinancialStatements.IncomeStatement.TotalRevenue.TwelveMonths
            annual_revenue:float = (revenue / curr_month) * 12
            
            if annual_revenue != 0:
                self.sea[symbol].append((stock.EarningReports.FileDate.Value, revenue / annual_revenue))
            
            if len(self.sea[symbol]) == self.sea[symbol].maxlen:
                curr_year:int = self.Time.year
                relevant_quarters:List[Tuple[float]] = [x for x in self.sea[symbol] if x[0].year == curr_year - 2 or x[0].year == curr_year - 3]
                # Make sure we have a consecutive seasonal data => 2 years by 4 quarters.
                if len(relevant_quarters) == self.consecutive_quarter_count:
                    avgsea:float = np.mean([x[1] for x in relevant_quarters])
                    avg_sea[stock] = avgsea
                    
                    # All time avg_sea values to calculate deciles.
                    self.avgsea_alltime.append(avgsea)
        
        if len(avg_sea) != 0:
            # Sort by SEA.
            long:List[Fundamental] = []
            short:List[Fundamental] = []
            
            # Wait for at least 100 last AVGSEA values to estimate percentile values.
            if len(self.avgsea_alltime) > self.avg_SEA_threshold: 
                avgsea_values:List[float] = [x for x in self.avgsea_alltime]
                
                top_decile:float = np.percentile(avgsea_values, self.percentiles[1])
                bottom_decile:float = np.percentile(avgsea_values, self.percentiles[0])
                
                for stock, avgsea in avg_sea.items():
                    if avgsea > top_decile:
                        short.append(stock)
                    elif avgsea < bottom_decile:
                        long.append(stock)
            else:
                return Universe.Unchanged
                
            # Market cap weighting.
            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 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.
        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()
class CustomFeeModel(FeeModel):
    def GetOrderFee(self, parameters):
        fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
        return OrderFee(CashAmount(fee, "USD"))

发表评论

了解 Quant Buffet 的更多信息

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

继续阅读