Overall Statistics |
Total Orders 51470 Average Win 0.12% Average Loss -0.11% Compounding Annual Return 4.131% Drawdown 28.600% Expectancy 0.026 Start Equity 100000 End Equity 182119.74 Net Profit 82.120% Sharpe Ratio 0.158 Sortino Ratio 0.19 Probabilistic Sharpe Ratio 0.040% Loss Rate 50% Win Rate 50% Profit-Loss Ratio 1.04 Alpha 0.01 Beta 0.11 Annual Standard Deviation 0.125 Annual Variance 0.016 Information Ratio -0.388 Tracking Error 0.177 Treynor Ratio 0.179 Total Fees $4314.88 Estimated Strategy Capacity $85000.00 Lowest Capacity Asset DWIN XLLPESHTRWX1 Portfolio Turnover 12.90% |
# https://quantpedia.com/strategies/announcement-adjusted-industry-relative-reversal-factor/ # # The investment universe mainly consists of all stocks listed on the NYSE and can also be extended to international equity markets. The main variable of interest # is the adjusted industry relative return (IRRX). This can be computed by first calculating a stock’s prior month’s return in excess of the industry return; can # use the return of an index that tracks the individual industry. Subsequently, the IRRX can be computed by adjusting this value by subtracting the three-day # cumulative abnormal return around the stock’s underlying firm’s most recent earnings announcement. The investment universe is sorted relative to this variable # and split up into quintiles. We focus on the extreme quintiles and short stocks in the first quintile and go long on stocks in the last quintile. Stocks are # weighted equally and the portfolio is rebalanced monthly. # # QC implementation changes: # - The investment universe consists of 3000 largest stocks from NYSE. # region imports from AlgorithmImports import * from dateutil.relativedelta import relativedelta from pandas.tseries.offsets import BDay import numpy as np from typing import Dict, List # endregion class AnnouncementAdjustedIndustryRelativeReversalFactor(QCAlgorithm): def Initialize(self) -> None: self.SetStartDate(2010, 1, 1) self.SetCash(100_000) self.market:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol self.ticker_to_ignore:List[str] = ['GME'] self.leverage:int = 3 self.quantile:int = 5 self.period:int = 31 self.fundamental_count:int = 3_000 self.data:Dict[Symbol, float] = {} self.earnings_dates:Dict[datetime.date, List[str]] = {} self.long:List[Symbol] = [] self.short:List[Symbol] = [] 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') + BDay(1)).date() if date not in self.earnings_dates: self.earnings_dates[date] = [] for stock_data in obj['stocks']: ticker:str = stock_data['ticker'] self.earnings_dates[date].append(ticker) self.selection_flag:bool = False self.Settings.MinimumOrderMarginPortfolioPercentage = 0. self.UniverseSettings.Resolution = Resolution.Daily self.AddUniverse(self.FundamentalSelectionFunction) self.Schedule.On(self.DateRules.MonthStart(self.market), self.TimeRules.AfterMarketOpen(self.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]: # store daily prices for stock in fundamental: symbol:Symbol = stock.Symbol if symbol in self.data: self.data[symbol].update_daily_return(self.Time, stock.AdjustedPrice) # selection on month start if not self.selection_flag: return Universe.Unchanged selected:List[Fundamental] = [x for x in fundamental if x.HasFundamentalData and x.Market == 'usa' and x.Symbol.Value not in self.ticker_to_ignore \ and x.MarketCap != 0 and not np.isnan(x.AssetClassification.MorningstarSectorCode) and x.AssetClassification.MorningstarSectorCode != 0 and \ (x.SecurityReference.ExchangeId == 'NYS')] if len(selected) > self.fundamental_count: selected = sorted(selected, key=lambda x: x.MarketCap, reverse=True)[:self.fundamental_count] selected:Dict[str, Fundamental] = {x.Symbol.Value: x for x in selected} # sort stocks on industry numbers and price warmup grouped_industries:Dict[MorningstarIndustryGroupCode, List[Symbol]] = {} for ticker, stock in selected.items(): symbol:Symbol = stock.Symbol industry_sector_code:int = stock.AssetClassification.MorningstarSectorCode if not industry_sector_code in grouped_industries: grouped_industries[industry_sector_code] = [] grouped_industries[industry_sector_code].append(symbol) if symbol in self.data: continue self.data[symbol] = SymbolData() history:DataFrame = self.History(symbol, self.period, Resolution.Daily) if history.empty: self.Log(f"Not enough data for {symbol} yet.") continue closes:pd.Series = history.loc[symbol].close for time, close in closes.items(): self.data[symbol].update_daily_return(time, close) irrx:Dict[Symbol, float] = {} # check earnings annoucement days for date, ticker_list in self.earnings_dates.items(): if date >= self.Time.date() - relativedelta(months=1) and date < self.Time.date(): for ticker in ticker_list: if ticker in selected: symbol:Symbol = selected[ticker].Symbol if self.data[symbol].is_ready() and all([self.data[x].is_ready() for x in grouped_industries[selected[ticker].AssetClassification.MorningstarSectorCode]]): symbol_announcement_returns:float = self.data[symbol].get_target_date_return(date) industry_announcement_returns:float = np.mean([self.data[x].get_target_date_return(date) for x in grouped_industries[selected[ticker].AssetClassification.MorningstarSectorCode]]) industry_returns:float = np.mean([self.data[x].get_monthly_return() for x in grouped_industries[selected[ticker].AssetClassification.MorningstarSectorCode]]) monthly_excess_return:float = self.data[symbol].get_monthly_return() - industry_returns irrx_ = monthly_excess_return - (symbol_announcement_returns - industry_announcement_returns) if irrx_ != sys.float_info.min: irrx[symbol] = irrx_ for symbol, symbol_data in self.data.items(): symbol_data.reset_daily_returns() if len(irrx) >= self.quantile: sorted_irrx:List[Symbol] = sorted(irrx, key=irrx.get) quantile:int = len(irrx) // self.quantile self.long = sorted_irrx[:quantile] self.short = sorted_irrx[-quantile:] return self.long + self.short def OnData(self, data: Slice) -> None: # monthly rebalance if not self.selection_flag: return self.selection_flag = False # order execution targets:List[PortfolioTarget] = [] for i, portfolio in enumerate([self.long, self.short]): for symbol in portfolio: if symbol in data and data[symbol]: targets.append(PortfolioTarget(symbol, ((-1) ** i) / len(portfolio))) self.SetHoldings(targets, True) self.long.clear() self.short.clear() def Selection(self) -> None: self.selection_flag = True # Custom fee model class CustomFeeModel(FeeModel): def GetOrderFee(self, parameters): fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005 return OrderFee(CashAmount(fee, "USD")) class SymbolData(): def __init__(self) -> None: self._last_price:float|None = None self._daily_return:List[Tuple[datetime.date, float]] = [] def update_daily_return(self, time:datetime, price:float) -> None: if self._last_price is not None: daily_return:float = (price - self._last_price) / self._last_price self._daily_return.append((time.date(), daily_return)) self._last_price = price def reset_daily_returns(self) -> None: self._daily_return.clear() def get_monthly_return(self) -> float: returns:List[float] = list(map(lambda x: x[1], self._daily_return)) return sum(returns) def get_target_date_return(self, date:datetime.date) -> float: #[i[0] for i in self._daily_return]: if date in list(map(lambda x: x[0], self._daily_return)): for i in range(len(self._daily_return) - 1): current_date, _ = self._daily_return[i] if current_date == date: return self._daily_return[i-1][1] + self._daily_return[i][1] + self._daily_return[i+1][1] else: return sys.float_info.min def is_ready(self) -> bool: return self._last_price is not None and len(self._daily_return) != 0