销售季节性溢价
登录后收藏学术论文
When Low Beats High: Riding the Sales Seasonality Premium
When Low Beats High: Riding the Sales Seasonality Premium [点击查看论文]
- Jones College
- Rice University
- ?Rice University - Jesse H. Jones Graduate School of Business
- Lehman College
- ?CUNY Lehman College
策略概要
该策略的投资范围包括在纽约证券交易所、美国证券交易所或纳斯达克上市的非金融类美国股票,不包括普通股。投资者使用SEA变量衡量销售季节性,该变量代表季度销售额除以年度销售额。为避免异常值,AVGSEA变量计算为前两年SEA的平均值。股票每月根据AVGSEA分为十个等级,做多最低等级的股票,做空最高等级的股票。该策略采用价值加权,持仓一个月,并每月进行再平衡。这种方法旨在从销售季节性中获取利润,并根据过往数据进行再平衡。
II. 策略合理性
该研究表明,销售和盈利异常在股票回报中并存,有证据表明高销售季节性公司与高资产增长公司相关。然而,溢价主要来自低销售季节性公司。投资者在低销售季度往往对股票关注较少,这是异常现象的核心原因。即使在控制了Fama-French的五个因子或动量等因素后,研究结果依然稳健。此外,即使在调整了价值加权投资组合、剔除了微市值股票并获得了高t统计量后,销售季节性溢价仍然显著。这表明该异常现象未被其他知名因子捕获,并且仍然是股票回报的强有力预测因子。
回测表现
波动率12.36%
夏普比率0.38
索提诺比率0.238
胜率53%
完整 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"))