Overall Statistics |
Total Orders 22530 Average Win 0.15% Average Loss -0.14% Compounding Annual Return 15.263% Drawdown 10.500% Expectancy 0.105 Start Equity 100000 End Equity 819389.03 Net Profit 719.389% Sharpe Ratio 1.218 Sortino Ratio 1.355 Probabilistic Sharpe Ratio 95.993% Loss Rate 47% Win Rate 53% Profit-Loss Ratio 1.07 Alpha 0.08 Beta 0.104 Annual Standard Deviation 0.073 Annual Variance 0.005 Information Ratio 0.005 Tracking Error 0.146 Treynor Ratio 0.862 Total Fees $11749.92 Estimated Strategy Capacity $24000.00 Lowest Capacity Asset SGMA R735QTJ8XC9X Portfolio Turnover 10.91% |
#region imports from AlgorithmImports import * from pandas.tseries.offsets import BDay #endregion class SymbolData(): def __init__(self, period:int) -> None: self.prices:RollingWindow = RollingWindow[float](period) def update(self, price:float) -> None: self.prices.Add(price) def is_ready(self) -> bool: return self.prices.IsReady def performance(self) -> float: prices:list[float] = list(self.prices) return (prices[0] - prices[-1]) / prices[-1] class ManagedSymbol(): def __init__(self, symbol:Symbol, date_to_switch:datetime.date, date_to_liquidate:datetime.date) -> None: self.symbol:Symbol = symbol self.date_to_switch:datetime.date = date_to_switch self.date_to_liquidate:datetime.date = date_to_liquidate class QuantpediaEarningsEps(PythonData): _earnings_universe:Set[str] = set() def GetSource(self, config, date, isLiveMode): return SubscriptionDataSource('data.quantpedia.com/backtesting_data/economic/{0}.json'.format(config.Symbol.Value.lower()), SubscriptionTransportMedium.RemoteFile, FileFormat.UnfoldingCollection) @staticmethod def get_earnings_universe() -> list: return list(QuantpediaEarningsEps._earnings_universe) def Reader(self, config, line, date, isLiveMode): objects:list[QuantpediaEarningsEps] = [] data:list[dict] = json.loads(line) end_time:datetime.date|None = None for index, sample in enumerate(data): custom_data:QuantpediaEarningsEps = QuantpediaEarningsEps() custom_data.Symbol = config.Symbol earnings_date:datetime.date = datetime.strptime(sample['date'], '%Y-%m-%d') # strategy trades 5 days before earnings day before_earnings_date:datetime.date = (earnings_date - BDay(5)).date() custom_data['earnings_date'] = earnings_date custom_data.Time = before_earnings_date custom_data.EndTime = custom_data.Time + timedelta(days=1) end_time = custom_data.EndTime curr_stocks:dict[str, dict] = {} for stock_data in sample['stocks']: ticker:str = stock_data['ticker'] QuantpediaEarningsEps._earnings_universe.add(ticker) curr_stocks[ticker] = { attribute: value for attribute, value in stock_data.items() } custom_data['curr_earnings_stocks'] = curr_stocks objects.append(custom_data) return BaseDataCollection(end_time, config.Symbol, objects) # Custom fee model class CustomFeeModel(FeeModel): def GetOrderFee(self, parameters): fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005 return OrderFee(CashAmount(fee, "USD"))
# https://quantpedia.com/strategies/post-earnings-announcement-drift-combined-with-strong-momentum/ # # The investment universe consists of all stocks from NYSE, AMEX and NASDAQ with a price greater than $5. Each quarter, all stocks are # sorted into deciles based on their 12 months past performance. The investor then uses only stocks from the top momentum decile and # goes long on each stock 5 days before the earnings announcement and closes the long position at the close of the announcement day. # Subsequently, at the close of the announcement day, he/she goes short and he/she closes his short position on the 5th day after the # earnings announcement. # # QC Implementation changes: # - Investment universe consist of stocks with earnings data available. from pandas.tseries.offsets import BDay from AlgorithmImports import * import data_tools class PostEarningsAnnouncementDriftCombinedwithStrongMomentum(QCAlgorithm): def Initialize(self): self.SetStartDate(2010, 1, 1) # earnings days data starts in 2010 self.SetCash(100_000) self.quantile: int = 10 self.min_share_price: int = 5 self.period: int = 12 * 21 # need n daily prices self.rebalance_period: int = 3 # referes to months, which has to pass, before next portfolio rebalance self.leverage: int = 5 self.data: Dict[Symbol, data_tools.SymbolData] = {} self.selected_symbols: List[Symbol] = [] # 50 equally weighted brackets for traded symbols self.managed_symbols_size: int = 50 self.managed_symbols: List[data_tools.ManagedSymbol] = [] # earning data parsing self.earnings: Dict[datetime.date, list[str]] = {} days_before_earnings: List[datetime.date] = [] earnings_set: Set(str) = set() # Source: https://www.nasdaq.com/market-activity/earnings 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[date] = [] days_before_earnings.append((date - BDay(5)).date()) for stock_data in obj['stocks']: ticker: str = stock_data['ticker'] self.earnings[date].append(ticker) earnings_set.add(ticker) self.earnings_universe: List[str] = list(earnings_set) self.symbol: Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol self.months_counter: int = 0 self.selection_flag: bool = False self.Settings.MinimumOrderMarginPortfolioPercentage = 0. self.settings.daily_precise_end_time = False self.UniverseSettings.Resolution = Resolution.Daily self.AddUniverse(self.FundamentalSelectionFunction) # Events on earnings days, before and after earning days. self.Schedule.On(self.DateRules.On(days_before_earnings), self.TimeRules.AfterMarketOpen(self.symbol), self.DaysBefore) 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]: # daily update of prices for stock in fundamental: symbol: Symbol = stock.Symbol if symbol in self.data: self.data[symbol].update(stock.AdjustedPrice) if not self.selection_flag: return Universe.Unchanged self.selection_flag = False selected: List[Symbol] = [ x.Symbol for x in fundamental if x.HasFundamentalData and x.Market == 'usa' and x.Price > self.min_share_price and x.Symbol.Value in self.earnings_universe ] # warm up prices for symbol in selected: if symbol in self.data: continue self.data[symbol] = data_tools.SymbolData(self.period) history: DataFrame = self.History(symbol, self.period, Resolution.Daily) if history.empty: self.Log(f"Not enough data for {symbol} yet") continue closes: Series = history.loc[symbol].close for _, close in closes.items(): self.data[symbol].update(close) # calculate momentum for each stock in self.earnings_universe momentum: Dict[Symbol, float] = { symbol: self.data[symbol].performance() for symbol in selected if self.data[symbol].is_ready() } if len(momentum) < self.quantile: self.selected_symbols = [] return Universe.Unchanged quantile: int = int(len(momentum) / self.quantile) sorted_by_mom: List[Symbol] = sorted(momentum, key=momentum.get) # the investor uses only stocks from the top momentum quantile self.selected_symbols = sorted_by_mom[-quantile:] return self.selected_symbols def DaysBefore(self) -> None: # every day check if 5 days from now is any earnings day earnings_date: datetime.date = (self.Time + BDay(5)).date() date_to_liquidate: datetime.date = (earnings_date + BDay(6)).date() if earnings_date not in self.earnings: return for symbol in self.selected_symbols: ticker: str = symbol.Value # is there any symbol which has earnings in 5 days if ticker not in self.earnings[earnings_date]: continue if (len(self.managed_symbols) < self.managed_symbols_size) and not self.Securities[symbol].Invested and \ self.Securities[symbol].Price != 0 and self.Securities[symbol].IsTradable: self.SetHoldings(symbol, 1 / self.managed_symbols_size) # NOTE: Must offset date to switch position by one day due to midnight execution of OnData function. # Alternatively, there's is a possibility to switch to BeforeMarketClose function. self.managed_symbols.append(data_tools.ManagedSymbol(symbol, (earnings_date + BDay(1)).date(), date_to_liquidate)) def OnData(self, data: Slice) -> None: # switch positions on earnings days. curr_date: datetime.date = self.Time.date() managed_symbols_to_delete: List[data_tools.ManagedSymbol] = [] for managed_symbol in self.managed_symbols: if managed_symbol.date_to_switch == curr_date: # switch position from long to short if managed_symbol.symbol in data and data[managed_symbol.symbol]: self.SetHoldings(managed_symbol.symbol, -1 / self.managed_symbols_size) elif managed_symbol.date_to_liquidate <= curr_date: self.Liquidate(managed_symbol.symbol) managed_symbols_to_delete.append(managed_symbol) # remove symbols from management for managed_symbol in managed_symbols_to_delete: self.managed_symbols.remove(managed_symbol) def Selection(self) -> None: # quarter selection if self.months_counter % self.rebalance_period == 0: self.selection_flag = True self.months_counter += 1