
“该策略涉及根据销售季节性投资于美国非金融股票。股票每月被分配到十个等级,做多最低的等级,做空最高的等级,并每月进行再平衡。”
资产类别: 股票 | 地区: 美国 | 周期: 每月 | 市场: 股票 | 关键词: 销售,季节性
I. 策略概要
该策略的投资范围包括在纽约证券交易所、美国证券交易所或纳斯达克上市的非金融类美国股票,不包括普通股。投资者使用SEA变量衡量销售季节性,该变量代表季度销售额除以年度销售额。为避免异常值,AVGSEA变量计算为前两年SEA的平均值。股票每月根据AVGSEA分为十个等级,做多最低等级的股票,做空最高等级的股票。该策略采用价值加权,持仓一个月,并每月进行再平衡。这种方法旨在从销售季节性中获取利润,并根据过往数据进行再平衡。
II. 策略合理性
该研究表明,销售和盈利异常在股票回报中并存,有证据表明高销售季节性公司与高资产增长公司相关。然而,溢价主要来自低销售季节性公司。投资者在低销售季度往往对股票关注较少,这是异常现象的核心原因。即使在控制了Fama-French的五个因子或动量等因素后,研究结果依然稳健。此外,即使在调整了价值加权投资组合、剔除了微市值股票并获得了高t统计量后,销售季节性溢价仍然显著。这表明该异常现象未被其他知名因子捕获,并且仍然是股票回报的强有力预测因子。
III. 来源论文
When Low Beats High: Riding the Sales Seasonality Premium [点击查看论文]
- 古斯塔沃·格鲁隆;亚米尔·卡巴;亚历山大·努涅斯,莱斯大学杰西·H·琼斯研究生商学院,纽约市立大学莱曼学院
<摘要>
我们证明,根据销售季节性对股票进行排序可以预测未来异常回报。买入低销售季节性股票并卖出高销售季节性股票的多空策略产生了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"))