Quant Buffet放轻松,别过度思虑

拆股后漂移与PEAD异常的结合

登录后收藏

学术论文

Post-Split Drift and Post-Earnings Announcement Drift: One Anomaly or Two?

作者盈余管理与拆分后漂移 [点击查看论文]

机构
  • TWNational Chengchi University
  • ?National Chengchi Unversity (NCCU) - Finance
  • Deakin University
  • ?Deakin University - Deakin Business School
  • HKUniversity of Hong Kong
  • ?The University of Hong Kong - Faculty of Business and Economics

策略概要

该策略针对纽约证券交易所(NYSE)、美国证券交易所(AMEX)和纳斯达克(NASDAQ)上市公司,重点关注财报公告和标准化意外盈余(SUE)。每天,筛选过去三个月内SUE处于最高或最低五分位的股票。投资者在财报公布后三天开始建仓,做多SUE位于最高五分位且近期发生拆股的股票,同时做空SUE位于最低五分位且无近期拆股记录的股票。所有头寸均等权重配置,持有期为三个月。该策略结合SUE和拆股信号,以捕捉市场潜在的非有效性。

II. 策略合理性

学术研究表明,股票拆分预示着未来收益的改善,分析师最初低估了拆分公司的收益,并且预测修正缓慢。包括分析师在内的投资者对这些信号反应不足,从而产生了拆分后的漂移效应。通过利用市场对与股票拆分相关的未来收益改善的延迟反应,将这种效应与盈余公告漂移(PEAD)异常相结合可以提高交易回报。这些异常之间的协同作用为系统性交易策略提供了有利可图的机会。

回测表现

波动率29.89%
夏普比率0.78
索提诺比率-1.861
胜率55%

完整 Python 代码

from AlgorithmImports import *
import data_tools
import numpy as np
from collections import deque
import dateutil.relativedelta
from dateutil.relativedelta import relativedelta
from typing import List, Dict, Set, Deque
from numpy import isnan
#endregion
class PostSplitDriftCombinedWithPEADAnomaly(QCAlgorithm):
def Initialize(self) -> None:
self.SetStartDate(2011, 1, 1)
self.SetCash(100_000)

self.symbol: Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol

# Set of unique symbols to trade.
self.long: Set = set()
self.short: Set = set()

self.leverage: int = 5
self.period: int = 13
self.long_symbols: int = 20
self.short_symbols: int = 20
self.holding_period: int = 90
self.percentiles: List[int] = [20, 80]
self.eps_data: Dict[Symbol, Deque[datetime, float]] = {}
# Import earnigns data.
self.earnings_data: Dict[datetime, str] = {}
# Import splits data
self.splits_data: Dict[str, datetime.date] = {}

# Surprise data count needed to count standard deviation.
self.surprise_period: int = 4
self.earnings_surprise: Dict[Deque[float]] = {}

# SUE history for previous quarter used for statistics.
self.sue_history_previous: Deque[float] = deque()
self.sue_history_actual: Deque[float] = deque()

self.first_date: Union[None, datetime.date] = None
earnings_data: str = self.Download('data.quantpedia.com/backtesting_data/economic/earnings_dates_eps.json')
earnings_data_json: List[dict] = json.loads(earnings_data)

for obj in earnings_data_json:
    date: datetime.date = datetime.strptime(obj['date'], "%Y-%m-%d").date()
    self.earnings_data[date] = []
    if not self.first_date: self.first_date = date
    for stock_data in obj['stocks']:
        ticker: str = stock_data['ticker']
        self.earnings_data[date].append(ticker)
        
csv_string_file: str = self.Download('data.quantpedia.com/backtesting_data/economic/splits.csv')
lines: str = csv_string_file.split('\r\n')
for line in lines:
    if line == '':
        continue
    line_split: str = line.split(';')
    symbol: str = line_split[0]
    
    self.splits_data[symbol] = []
    
    for i in range(1, len(line_split)):
        if line_split[i] is not '':
            date: datetime = datetime.strptime(line_split[i], '%m/%d/%Y').date()
            self.splits_data[symbol].append(date)

# 70 equally weighted brackets for traded symbols. - 20 symbols long, 20 symbols short, 90 days of holding.
self.trade_manager: data_tools.TradeManager = data_tools.TradeManager(self, self.long_symbols, self.short_symbols, self.holding_period)

self.month: int = 12
self.selection_flag: bool = True
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
self.settings.daily_precise_end_time = False
self.UniverseSettings.Resolution = Resolution.Daily
self.AddUniverse(self.FundamentalSelectionFunction)
self.Schedule.On(self.DateRules.MonthStart(self.symbol), self.TimeRules.AfterMarketOpen(self.symbol), self.Selection)
def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
for security in changes.AddedSecurities:
    security.SetFeeModel(data_tools.CustomFeeModel())
    security.SetLeverage(self.leverage)

def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
if not self.selection_flag:
    return Universe.Unchanged
self.selection_flag = False

# S&P 500 universe from self.splits_data tickers.
selected: List[Fundamental] = [x for x in fundamental if x.HasFundamentalData and x.Market == 'usa' \
                        and not isnan(x.EarningReports.BasicEPS.ThreeMonths) and x.EarningReports.BasicEPS.ThreeMonths != 0 \
                        and not isnan(x.EarningReports.FileDate.Value.year) and x.EarningReports.FileDate.Value.year != 1 \
                        and x.Symbol.Value in self.splits_data]
# SUE data.
sue_data: Dict[Symbol, float] = {}
for stock in selected:
    symbol: Symbol = stock.Symbol
    # Store eps data.
    if symbol not in self.eps_data:
        self.eps_data[symbol] = deque(maxlen = self.period)
    data: Tuple[datetime.date, float] = (stock.EarningReports.FileDate.Value.date(), stock.EarningReports.BasicEPS.ThreeMonths)
    # NOTE: Handles duplicate values. QC fine contains duplicated stocks in some cases.
    if data not in self.eps_data[symbol]:
        self.eps_data[symbol].append(data)
        
    if len(self.eps_data[symbol]) == self.eps_data[symbol].maxlen:
        recent_eps_data: datetime = self.eps_data[symbol][-1]
        
        year_range: List[int] = range(self.Time.year - 3, self.Time.year)
        
        last_month_date: datetime.date = recent_eps_data[0] + relativedelta(months = -1)
        next_month_date: datetime.date = recent_eps_data[0] + relativedelta(months = 1)
        month_range: List[int] = [last_month_date.month, recent_eps_data[0].month, next_month_date.month]
        # Earnings with todays month number 4 years back.
        seasonal_eps_data: List[Tuple[datetime.date, float]] = [x for x in self.eps_data[symbol] if x[0].month in month_range and x[0].year in year_range]
        if len(seasonal_eps_data) != 3:
            continue
        
        # Make sure we have a consecutive seasonal data. Same months with one year difference.
        year_diff: np.ndarray = np.diff([x[0].year for x in seasonal_eps_data])
        if all(x == 1 for x in year_diff):
            seasonal_eps: List[float] = [x[1] for x in seasonal_eps_data]
            diff_values: List[float] = np.diff(seasonal_eps)
            drift: float = np.average(diff_values)
            
            # SUE calculation.
            last_earnings: float = seasonal_eps[-1]
            expected_earnings: float = last_earnings + drift
            actual_earnings: float = recent_eps_data[1]
            # Store sue value with earnigns date.
            earnings_surprise: float = actual_earnings - expected_earnings
            if symbol not in self.earnings_surprise:
                self.earnings_surprise[symbol] = deque()
            else:
                # Surprise data is ready.
                if len(self.earnings_surprise[symbol]) >= self.surprise_period:
                    earnings_surprise_std:float = np.std(self.earnings_surprise[symbol])
                    sue: float = earnings_surprise / earnings_surprise_std

                    sue_data[symbol] = sue
                        
                    # Store pair in this month's history.
                    self.sue_history_actual.append(sue)
            self.earnings_surprise[symbol].append(earnings_surprise)
        
if len(sue_data) != 0: 
    # Wait until we have history data for previous three months.
    if len(self.sue_history_previous) != 0:
        # Sort by SUE
        top_sue_quintile: float = np.percentile(self.sue_history_previous, self.percentiles[1])
        bottom_sue_quintile: float = np.percentile(self.sue_history_previous, self.percentiles[0])
        self.long = [x[0] for x in sue_data.items() if x[1] >= top_sue_quintile]
        self.short = [x[0] for x in sue_data.items() if x[1] <= bottom_sue_quintile]
 
return list(self.long) + list(self.short)

def OnData(self, data: Slice) -> None:
# Liquidate opened symbols after 90 days.
self.trade_manager.TryLiquidate()
current_date: datetime.date = (self.Time).date()
three_months_ago: datetime.date = (current_date - dateutil.relativedelta.relativedelta(months=3))
date_to_lookup: datetime.date = (self.Time - timedelta(days=3)).date()

# If there is no earnings data yet.
if date_to_lookup < self.first_date:
    # Clear long and short set.
    self.long.clear()
    self.short.clear()
# Open new trades.
symbols_to_delete: List[Symbol] = []
if date_to_lookup in self.earnings_data:
    for symbol in self.long:
        if symbol.Value in self.earnings_data[date_to_lookup]: # Check if stock had earnings date three days before this one
            if symbol.Value in self.splits_data:
                if self.CheckSplitDate(symbol.Value, three_months_ago, current_date):
                    self.trade_manager.Add(symbol, True)
                    symbols_to_delete.append(symbol)
                
    for symbol in self.short:
        if symbol.Value in self.earnings_data[date_to_lookup]: # Check if stock had earnings date three days before this one
            if symbol.Value in self.splits_data:
                if self.CheckSplitDate(symbol.Value, three_months_ago, current_date):
                    self.trade_manager.Add(symbol, False)
                    symbols_to_delete.append(symbol)
                    
# Delete already traded symbols from long or short set
for symbol in symbols_to_delete:
    if symbol in self.long:
        self.long.remove(symbol)
    if symbol in self.short:
        self.short.remove(symbol)

def CheckSplitDate(self, symbol: Symbol, date_from: datetime.date, date_to: datetime.date) -> bool:
for split_date in self.splits_data[symbol]:
    if date_from <= split_date <= date_to:
        return True
    
return False

def Selection(self) -> None:
self.selection_flag = True

# Every three months.    
if self.month % 3 == 0:
    # Save previous month history.
    self.sue_history_previous = [x for x in self.sue_history_actual]
    self.sue_history_actual.clear()
self.month += 1
if self.month > 12:
    self.month = 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"))