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