
“该策略通过利用季节性反转交易纽约证券交易所、美国证券交易所和纳斯达克股票,做多低平均回报投资组合,做空高平均回报投资组合,每月进行价值加权重新平衡。”
资产类别: 股票 | 地区: 美国 | 周期: 每月 | 市场: 股票 | 关键词: 看多、看空
I. 策略概要
该策略的目标是纽约证券交易所、美国证券交易所和纳斯达克的股票,不包括非普通股。在t月,通过根据过去20年的数据,按股票在t月以外的月份的平均回报将股票分为六个投资组合,构建季节性反转因子。该策略做多平均回报最低的两个投资组合,做空平均回报最高的两个投资组合。投资组合按价值加权,每月重新平衡,利用季节性回报模式捕捉反转机会。
II. 策略合理性
股票回报的季节性和预期回报的差异通过季节性反转来平衡,确保长期预期回报不受影响。当一只股票在特定月份表现优异时,其在其他月份的回报往往较低,从而满足累加约束。这表明季节性是由暂时性错误定价而非基于风险的因素引起的,因为风险溢价本质上不会累加为零。当交易者将价格暂时推高或推低至基本价值之上或之下时,就会发生错误定价,而反转会纠正这些偏差。季节性反转与短期反转、动量或长期反转不同,尽管与后者有一些相似之处,但驱动它们的机制不同。
III. 来源论文
Are Return Seasonalities Due to Risk or Mispricing? Evidence from Seasonal Reversals [点击查看论文]
- 马蒂·凯洛哈留(Matti Keloharju)、朱哈尼·林奈恩玛(Juhani T. Linnainmaa)和彼得·尼伯格(Peter Nyberg)。阿尔托大学商学院;工业经济研究所(IFN);经济政策研究中心(CEPR)。达特茅斯学院塔克商学院;国家经济研究局(NBER);Kepos Capital。阿尔托大学。
<摘要>
股票每年在同一月份相对于其他股票而言,往往会获得较高或较低的回报(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"))