| 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