Created with Highcharts 12.1.2Equity20092010201120122013201420152016201720182019202020212022202320242025202650k100k150k-20-10000.5-101025M50M01M2M01020
Overall Statistics
Total Orders
10036
Average Win
0.20%
Average Loss
-0.19%
Compounding Annual Return
1.716%
Drawdown
18.900%
Expectancy
0.031
Start Equity
100000
End Equity
131693.45
Net Profit
31.693%
Sharpe Ratio
-0.071
Sortino Ratio
-0.054
Probabilistic Sharpe Ratio
0.017%
Loss Rate
50%
Win Rate
50%
Profit-Loss Ratio
1.05
Alpha
-0.005
Beta
0.018
Annual Standard Deviation
0.052
Annual Variance
0.003
Information Ratio
-0.622
Tracking Error
0.154
Treynor Ratio
-0.202
Total Fees
$3602.82
Estimated Strategy Capacity
$2000000.00
Lowest Capacity Asset
ALT WK9O766FC2UD
Portfolio Turnover
11.25%
from AlgorithmImports import *

import numpy as np
from scipy.optimize import minimize

class TradeManager():
    def __init__(self, algorithm: QCAlgorithm, long_size: int, short_size: int, holding_period: int) -> None:
        self.algorithm: QCAlgorithm = algorithm  # algorithm to execute orders in.
        
        self.long_size: int = long_size
        self.short_size: int = short_size
        
        self.long_len: int = 0
        self.short_len: int = 0
    
        # Arrays of ManagedSymbols
        self.symbols: List[ManagedSymbol] = []
        
        self.holding_period: int = holding_period    # Days of holding.
    
    # Add stock symbol object
    def Add(self, symbol: Symbol, long_flag: bool) -> None:
        # Open new long trade.
        managed_symbol: ManagedSymbol = ManagedSymbol(symbol, self.holding_period, long_flag)
        
        if long_flag:
            # If there's a place for it.
            if self.long_len < self.long_size:
                self.symbols.append(managed_symbol)
                self.algorithm.SetHoldings(symbol, 1 / self.long_size)
                self.long_len += 1
            else:
                self.algorithm.Log(f"There's no place for additional trade: {symbol}")

        # Open new short trade.
        else:
            # If there's a place for it.
            if self.short_len < self.short_size:
                self.symbols.append(managed_symbol)
                self.algorithm.SetHoldings(symbol, - 1 / self.short_size)
                self.short_len += 1
            else:
                self.algorithm.Log(f"There's no place for additional trade: {symbol}")
   
    # Decrement holding period and liquidate symbols.
    def TryLiquidate(self) -> None:
        symbols_to_delete: List[Symbol] = []
        for managed_symbol in self.symbols:
            managed_symbol.days_to_liquidate -= 1
            
            # Liquidate.
            if managed_symbol.days_to_liquidate == 0:
                symbols_to_delete.append(managed_symbol)
                self.algorithm.Liquidate(managed_symbol.symbol)
                
                if managed_symbol.long_flag: self.long_len -= 1
                else: self.short_len -= 1

        # Remove symbols from management.
        for managed_symbol in symbols_to_delete:
            self.symbols.remove(managed_symbol)
    
    def LiquidateTicker(self, ticker: str) -> None:
        symbol_to_delete = None
        for managed_symbol in self.symbols:
            if managed_symbol.symbol.Value == ticker:
                self.algorithm.Liquidate(managed_symbol.symbol)
                symbol_to_delete = managed_symbol
                if managed_symbol.long_flag: self.long_len -= 1
                else: self.short_len -= 1
                
                break
        
        if symbol_to_delete: self.symbols.remove(symbol_to_delete)
        else: self.algorithm.Debug("Ticker is not held in portfolio!")
    
class ManagedSymbol():
    def __init__(self, symbol: Symbol, days_to_liquidate: int, long_flag: bool) -> None:
        self.symbol: Symbol = symbol
        self.days_to_liquidate: int = days_to_liquidate
        self.long_flag: bool = long_flag
        
class SymbolData():
    def __init__(self, volume: float, period: int) -> None:
        self.Volume = RollingWindow[float](period)
        
    def update(self, volume: float) -> None:
        self.Volume.Add(volume)
        
    def is_ready(self) -> bool:
        return self.Volume.IsReady
        
    def sum_volumes(self) -> float:
        volumes = [x for x in self.Volume]
        return sum(volumes)

# Custom fee model.
class CustomFeeModel(FeeModel):
    def GetOrderFee(self, parameters: OrderFeeParameters) -> OrderFee:
        fee: float = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
        return OrderFee(CashAmount(fee, "USD"))
# https://quantpedia.com/strategies/instititutional-ownership-effect-during-earnings-announcements/
#
# The investment universe consists of stocks from the Russell 3000 index.
# Stocks are sorted into quintiles based on institutional ownership and volume.
# The investor uses only stocks with the lowest ownership and the highest volume and periodically checks which stocks
# have close to their earnings announcement date. He goes long stocks two days before announcements and shorts stocks two days after announcements.
# Stocks are weighted equally, and the portfolio is rebalanced daily.
# 
# QC implementation changes:
#   - Stock ownership is proxied by market cap.

import data_tools
from AlgorithmImports import *
import numpy as np
from pandas.tseries.offsets import BDay

class InstititutionalOwnershipEffectDuringEarningsAnnouncements(QCAlgorithm):
    
    def Initialize(self) -> None:
        self.SetStartDate(2009, 1, 1) # earnings dates starts at 2010
        self.SetCash(100_000)

        self.period: int = 21
        self.lookup_period: int = 2
        self.holding_period: int = 2
        self.min_share_price: int = 5

        self.leverage: int = 5
        self.quantile: int = 5

        self.total_long_num: int = 15
        self.total_short_num: int = 15
        
        self.long: Set(Symbol) = set()
        self.short: Set(Symbol) = set()

        self.data: Dict[Symbol, data_tools.SymbolData] = {}
        self.earnings_data: Dict[datetime.date, list[str]] = {}
        
        self.first_date: Union[None, datetime.date] = None

        earnings_set: 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()

            if not self.first_date: self.first_date = date

            self.earnings_data[date] = []
            
            for stock_data in obj['stocks']:
                ticker: str = stock_data['ticker']

                self.earnings_data[date].append(ticker)
                earnings_set.add(ticker)
        
        self.tickers: List[str] = list(earnings_set)

        market: Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol

        # equally weighted brackets for traded symbols. - n symbols long, m symbols short, 2 days of holding
        self.trade_manager: data_tools.TradeManager = data_tools.TradeManager(
            self, self.total_long_num, self.total_short_num, self.holding_period)
        
        self.fundamental_count: int = 3000
        self.fundamental_sorting_key = lambda x: x.MarketCap

        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)
        self.Schedule.On(self.DateRules.MonthStart(market), self.TimeRules.AfterMarketOpen(market), 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]:
        # update the rolling window every day
        for stock in fundamental:
            symbol: Symbol = stock.Symbol

            if symbol in self.data:
                self.data[symbol].update(stock.Volume)

        if not self.selection_flag:
            return Universe.Unchanged
        self.selection_flag = False

        selected: List[Fundamental] = [
            x for x in fundamental if x.Symbol.Value in self.tickers \
            and x.HasFundamentalData and x.MarketCap != 0 and x.Market == 'usa' and x.Price > self.min_share_price
        ]

        if len(selected) > self.fundamental_count:
            selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]

        warmed_up_symbols:List[Symbol] = []
        for stock in selected:
            symbol: Symbol = stock.Symbol

            if symbol not in self.data:
                self.data[symbol] = data_tools.SymbolData(symbol, self.period)
                history: DataFrame = self.History(symbol, self.period, Resolution.Daily)
                if history.empty:
                    continue
                if not hasattr(history.loc[symbol], 'volume'):
                    continue
                volumes: Series = history.loc[symbol].volume
                for _, volume in volumes.items():
                    self.data[symbol].update(volume)

            if self.data[symbol].is_ready():
                warmed_up_symbols.append(symbol)
        
        if len(warmed_up_symbols) < self.quantile:
            return Universe.Unchanged

        quantile: int = int(len(warmed_up_symbols) / self.quantile)
        lowest_market_caps: List[Symbol] = [x for x in warmed_up_symbols[-quantile:]]
        
        volumes: Dict[Symbol, float] = { x : self.data[x].sum_volumes() for x in warmed_up_symbols}
        
        quantile: int = int(len(volumes) / self.quantile)
        highest_volumes: List[Symbol] = [x[0] for x in sorted(volumes.items(), key=lambda item: item[1])][-quantile:]
        
        self.long = set(symbol for symbol in lowest_market_caps if symbol in highest_volumes)

        return list(self.long)

    def OnData(self, data: Slice) -> None:
        # liquidate opened symbols after self.holding_period days.
        self.trade_manager.TryLiquidate()
        
        # long two days before earnings annoucement
        date_to_lookup_long: datetime.date = (self.Time + BDay(self.lookup_period)).date()
        # short two days after earnings annoucement
        date_to_lookup_short: datetime.date = (self.Time - BDay(self.lookup_period)).date()
        
        if date_to_lookup_long < self.first_date:
            self.long.clear()

        # open new trades
        symbols_to_delete: List[Symbol] = []
        if date_to_lookup_long in self.earnings_data:
            for symbol in self.long:
                if symbol.Value in self.earnings_data[date_to_lookup_long] and symbol in data and data[symbol]:
                    self.trade_manager.Add(symbol, True)
                    symbols_to_delete.append(symbol)
        
        # delete already traded symbols and add them to short portfolio
        for symbol in symbols_to_delete:
            self.long.remove(symbol)
            self.short.add(symbol)
            
        symbols_to_delete.clear()
        if date_to_lookup_short in self.earnings_data:
            for symbol in self.short:
                if symbol.Value in self.earnings_data[date_to_lookup_short] and symbol in data and data[symbol]:
                    self.trade_manager.Add(symbol, False)
                    symbols_to_delete.append(symbol)
                    
        for symbol in symbols_to_delete:
            self.short.remove(symbol)
        
    def Selection(self) -> None:
        self.selection_flag = True