“该策略做多先前盈利意外较低的公司,做空盈利意外较高的公司,持仓两天。投资组合根据市值按价值加权。”

I. 策略概要

该策略针对纽约证券交易所规模排名前五分之一的大公司股票,采用每日多空方法。在先前盈利意外较低的日子,投资者做多公布盈利的公司,做空市场。在高盈利意外的日子,投资者做空这些公司,做多市场。投资组合根据三天前的市值按价值加权,持仓两天。该策略利用过去盈利意外与即将发布的盈利公告表现之间的关系获利。

II. 策略合理性

该策略基于对比效应,这是一种认知偏差,投资者对最近的信息反应更强烈。今天公布盈利的公司的回报受到前一天盈利意外的负面影响,但受t-2或t-3天的过去意外以及t+1和t+2天的未来意外的影响不显著。方向性效应表明,前一天的大幅意外会使今天的意外(即使是正面的)看起来更糟。这种回报扭曲受到昨天意外的强烈影响,与今天的意外没有显著的相互作用。

III. 来源论文

A Tough Act to Follow: Contrast Effects in Financial Markets [点击查看论文]

<摘要>

当先前观察到的信号的价值反向偏置对下一个信号的感知时,就会发生对比效应。我们首次提供了证据,表明对比效应会扭曲复杂且流动性强的市场的价格。如果昨天的盈利意外很糟糕,投资者会错误地认为今天的盈利消息更令人印象深刻;如果昨天的盈利意外很好,则会认为今天的盈利消息不那么令人印象深刻。我们金融环境的一个独特优势在于,我们可以将对比效应识别为感知而非预期的错误。最后,我们表明,我们的结果无法用涉及先前盈利公告信息传递的关键替代解释来解释。

IV. 回测表现

年化回报15%
波动率N/A
β值-0.041
夏普比率N/A
索提诺比率-0.167
最大回撤N/A
胜率50%

V. 完整的 Python 代码

from AlgorithmImports import *
import numpy as np
from collections import deque
from pandas.tseries.offsets import BDay
from dateutil.relativedelta import relativedelta
from typing import Dict, List
#endregion
class ContrastEffectDuringtheEarningsAnnouncements(QCAlgorithm):
    def Initialize(self):
        self.SetStartDate(2010, 1, 1)   # scheduled earnings data starts in 2010
        self.SetCash(100000)
        self.leverage:int = 5
        self.seasonal_eps_count:int = 3
        self.holding_period:int = 2
        self.surprise_period:int = 4
        self.period:int = 13
        # trenching
        self.managed_queue:List[RebalanceQueueItem] = []
        
        # surprise data count needed to count standard deviation
        self.earnings_surprise:Dict[Symbol, deque] = {}
           
        self.last_price:Dict[Symbol, float] = {}
        
        # SUE and EAR history for previous quarter used for statistics
        self.surprise_history_previous:deque = deque()
        self.surprise_history_actual:deque = deque()
        self.eps:Dict[Symbol, deque] = {}
        data = self.AddEquity('SPY', Resolution.Daily)
        data.SetFeeModel(CustomFeeModel())
        data.SetLeverage(self.leverage)
        self.symbol:Symbol = data.Symbol
        # Earning data parsing.
        self.earnings_data:Dict[datetime.date, Dict[str, float]] = {}
        self.tickers:Set(str) = set() 
        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] = {}
            
            for stock_data in obj['stocks']:
                ticker:str = stock_data['ticker']
                if stock_data['eps'] != '':
                    self.earnings_data[date][ticker] = float(stock_data['eps'])
                    
                self.tickers.add(ticker)
        
        self.month:int = 0
        self.settings.daily_precise_end_time = False
        self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
        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(CustomFeeModel())
            security.SetLeverage(self.leverage)
    
    def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
        self.last_price.clear()
        for equity in fundamental:
            symbol:Symbol = equity.Symbol
            ticker:str = symbol.Value
            if ticker in self.tickers or symbol == self.symbol:
                self.last_price[symbol] = equity.AdjustedPrice
        selected:List[FineFundamental] = [x for x in fundamental if x.MarketCap != 0]
        # make sure there are some stocks with yesterday's earnings
        yesterday:datetime.date = (self.Time - BDay(1)).date()
        if yesterday not in self.earnings_data:
            return Universe.Unchanged
        tickers_with_yesterday_earnings:List[str] = list(self.earnings_data[yesterday].keys())
        # stocks with yesterday's earnings
        filtered_fine:List[Fundamental] = [x for x in selected if x.Symbol.Value in tickers_with_yesterday_earnings] 
        # SUE data
        sue_data:Dict[Symbol, float] = {}
        
        for stock in filtered_fine:
            symbol:Symbol = stock.Symbol
            ticker:str = symbol.Value
            # store eps data
            if symbol not in self.eps:
                self.eps[symbol] = deque(maxlen = self.period)
            data:List[datetime.date, float] = [yesterday, self.earnings_data[yesterday][ticker]]
            self.eps[symbol].append(data)
            
            # consecutive EPS data
            if len(self.eps[symbol]) == self.eps[symbol].maxlen:
                recent_eps_data:float = self.eps[symbol][-1]
                
                year_range:range = 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[List[datetime.date, float]] = [x for x in self.eps[symbol] \
                    if x[0].month in month_range and x[0].year in year_range]
                if len(seasonal_eps_data) != self.seasonal_eps_count: continue
                
                # Make sure we have a consecutive seasonal data. Same months with one year difference.
                year_diff:np.array = 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:np.array = 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(maxlen=self.surprise_period)
                    elif 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
                    self.earnings_surprise[symbol].append(earnings_surprise)
        
        if len(sue_data) == 0:
            return Universe.Unchanged
        
        long_symbol_q:List[Symbol, float] = []
        short_symbol_q:List[Symbol, float] = []
        
        # store total yesterday's surprise in this month's history
        yesterdays_surprises:float = sum([x[1] for x in sue_data.items()])
        # wait until there is surprise history data for previous three months
        if len(self.surprise_history_previous) != 0:
            # find symbols with next day scheduled earnings
            earnings_date = (self.Time + BDay(1)).date()
            if earnings_date in self.earnings_data:
                surprise_values:List = [x for x in self.surprise_history_previous]
            
                top_surprise_percentile:float  = np.percentile(surprise_values, 75)
                bottom_surprise_percentile:float = np.percentile(surprise_values, 25)
                
                traded_symbols:List[List[Symbol, float]] = []
                for stock in selected:
                    symbol:Symbol = stock.Symbol
                    ticker:str = symbol.Value
                    # stock has earnings in 1 day
                    if ticker in self.earnings_data[earnings_date]:
                        traded_symbols.append([symbol, stock.MarketCap])
                
                if len(traded_symbols) != 0:
                    if self.symbol in self.last_price:
                        total_market_cap:float = sum([x[1] for x in traded_symbols])
                        
                        stocks_w:float = self.Portfolio.TotalPortfolioValue / self.holding_period
                        spy_quantity:float = self.Portfolio.TotalPortfolioValue / self.holding_period / self.last_price[self.symbol]
                        
                        if yesterdays_surprises > top_surprise_percentile:
                            long_symbol_q = [(x[0], np.floor(stocks_w * (x[1] / total_market_cap) / self.last_price[x[0]])) for x in traded_symbols]
                            
                            # Quantity instead of weight is used in case of SPY.
                            short_symbol_q = [(self.symbol, -spy_quantity)]
                            
                        elif yesterdays_surprises < bottom_surprise_percentile:
                            # Quantity instead of weight is used in case of SPY.
                            long_symbol_q = [(self.symbol, spy_quantity)]
                            
                            short_symbol_q = [(x[0], -np.floor(stocks_w * (x[1] / total_market_cap) / self.last_price[x[0]])) for x in traded_symbols]  
            
        self.surprise_history_actual.append(yesterdays_surprises)
            
        self.managed_queue.append(RebalanceQueueItem(long_symbol_q + short_symbol_q))
            
        return [x[0] for x in long_symbol_q + short_symbol_q] if len(long_symbol_q + short_symbol_q) != 0 else Universe.Unchanged
    def OnData(self, data: Slice) -> None:
        # trade execution
        remove_item:Union[RebalanceQueueItem, None] = None
        
        # rebalance portfolio
        for item in self.managed_queue:
            if item.holding_period == self.holding_period:
                for symbol, quantity in item.symbol_q:
                    self.MarketOrder(symbol, -quantity)
                            
                remove_item = item
                
            elif item.holding_period == 0:
                open_symbol_q:List[List[Symbol, float]] = []
                
                for symbol, quantity in item.symbol_q:
                    if symbol in data and data[symbol]:
                        self.MarketOrder(symbol, quantity)
                        open_symbol_q.append((symbol, quantity))
                            
                # Only opened orders will be closed        
                item.symbol_q = open_symbol_q
                
            item.holding_period += 1
            
        # remove closed part of portfolio after loop
        # otherwise it will miss one item in self.managed_queue
        if remove_item:
            self.managed_queue.remove(remove_item)
            
    def Selection(self) -> None:        
        # every three months
        if self.month % 3 == 0:
            # save history
            self.surprise_history_previous = [x for x in self.surprise_history_actual]
            self.surprise_history_actual.clear()
        
        self.month += 1
            
class RebalanceQueueItem:
    def __init__(self, symbol_q):
        # symbol/quantity collections
        self.symbol_q:List[List[Symbol, float]] = symbol_q  
        self.holding_period:int = 0
        
# Custom fee model
class CustomFeeModel(FeeModel):
    def GetOrderFee(self, parameters):
        fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
        return OrderFee(CashAmount(fee, "USD"))

发表评论

了解 Quant Buffet 的更多信息

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

继续阅读