Overall Statistics
Total Orders
1592
Average Win
0.56%
Average Loss
-0.74%
Compounding Annual Return
-3.369%
Drawdown
61.200%
Expectancy
-0.083
Start Equity
100000
End Equity
65486.12
Net Profit
-34.514%
Sharpe Ratio
-0.199
Sortino Ratio
-0.235
Probabilistic Sharpe Ratio
0.000%
Loss Rate
48%
Win Rate
52%
Profit-Loss Ratio
0.76
Alpha
0.021
Beta
-0.665
Annual Standard Deviation
0.159
Annual Variance
0.025
Information Ratio
-0.411
Tracking Error
0.27
Treynor Ratio
0.048
Total Fees
$1661.46
Estimated Strategy Capacity
$0
Lowest Capacity Asset
KLC XTN6UA1G9Q3P
Portfolio Turnover
0.72%
# region imports
from AlgorithmImports import *
from dataclasses import dataclass
# endregion

# NOTE: Manager for new trades. It's represented by certain count of equally weighted brackets for long and short positions.
# If there's a place for new trade, it will be managed for time of holding period.
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, 
        price: float) -> None:
    
        # Open new long trade.
        quantity: Union[None, int] = None
        
        if long_flag:
            # If there's a place for it.
            if self.long_len < self.long_size:
                quantity: int = int(self.algorithm.portfolio.total_portfolio_value / self.long_size / price)

                self.algorithm.market_order(symbol, quantity)
                self.long_len += 1
            # else:
            #     self.algorithm.Log("There's not place for additional trade.")

        # Open new short trade.
        else:
            # If there's a place for it.
            if self.short_len < self.short_size:
                quantity: int = int(self.algorithm.portfolio.total_portfolio_value / self.short_size / price)

                self.algorithm.market_order(symbol, -quantity)
                self.short_len += 1
            # else:
                # self.algorithm.Log("There's not place for additional trade.")
   
        if quantity:
            managed_symbol: ManagedSymbol = ManagedSymbol(symbol, self.holding_period, quantity, long_flag)
            self.symbols.append(managed_symbol)

    # Decrement holding period and liquidate symbols.
    def try_liquidate(self) -> None:
        symbols_to_delete: List[ManagedSymbol] = []
        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)
                    
                if managed_symbol.long_flag:
                    self.algorithm.market_order(managed_symbol.symbol, -managed_symbol.quantity)
                    self.long_len -= 1
                else:
                    self.algorithm.market_order(managed_symbol.symbol, managed_symbol.quantity)
                    self.short_len -= 1
                

        # Remove symbols from management.
        for managed_symbol in symbols_to_delete:
            self.symbols.remove(managed_symbol)
    
    def liquidate_ticker(self, ticker: str) -> None:
        symbol_to_delete: Union[None, Symbol] = None
        for managed_symbol in self.symbols:
            if managed_symbol.symbol.Value == ticker:
                if managed_symbol.long_flag:
                    self.algorithm.market_order(managed_symbol.symbol, -managed_symbol.quantity)
                    self.long_len -= 1
                else:
                    self.short_len -= 1
                    self.algorithm.market_order(managed_symbol.symbol, managed_symbol.quantity)
                symbol_to_delete = managed_symbol
                
                break
        
        if symbol_to_delete: self.symbols.remove(symbol_to_delete)
        else: self.algorithm.Debug("Ticker is not held in portfolio!")

@dataclass   
class ManagedSymbol():
    symbol: Symbol 
    days_to_liquidate: int 
    quantity: int 
    long_flag: bool
# https://quantpedia.com/strategies/ipos-negative-returns-after-options-listing
# 
# The sample consists of U.S. companies that went public between 1996 and 2017 and had options listed within 156 weeks (3 years) after the IPO issuance date 
# (newly public companies, specifically those that have gone public within the last three years and have options listed on their equity). Individual instruments 
# are selected based on the presence of options trading activity and the affiliation of the IPO lead underwriter with the Options Clearing Corporation (OCC). 
# Companies where the lead underwriter is an OCC member are prioritized, as this affiliation suggests a potential private information source that proprietary 
# traders might exploit.
# (U.S. initial public offerings data on ordinary common shares from the Securities DataCompany (SDC) New Issues database, security prices, and trading 
# information from the Center for Research in
# Security Prices (CRSP), information on option prices and exchanges’ listing dates is collected from OptionMetrics, which covers all U.S. exchange-listed 
# equities and market indices, and security-lending data from Markit Securities Finance, open-close options volume data from 8 options exchange: acquired from 
# two sources: the Chicago Board of Options Exchange (CBOE) and the NASDAQ, each operating multiple options exchange.)
# Main Principle Recapitulation: The strategy idea is is capitalizing on finding that when options are listed for newly issued IPOs, their returns decline. In 
# ideal circumstances, investor should start shorting IPO stocks 12 weeks before options are listed for newly issued IPOs and cover short position 24 weeks 
# after options are introduced.
# Rebalancing & Weighting: Rebalanced dynamically, weekly, if there is a corresponding signal for inclusion (see first sentence of the first paragraph). 
# Consider weighting options in a value-weighted manner.
# 
# QC Implementation changes:
#   - Trading is executed on the date when the first option chain becomes available for the given stock.

# region imports
from AlgorithmImports import *
from dateutil.relativedelta import relativedelta
from pandas.tseries.offsets import BDay
import data_tools
# endregion

class IPOsNegativeReturnsAfterOptionsListing(QCAlgorithm):

    def initialize(self) -> None:
        self.set_start_date(2013, 1, 1)
        self.set_cash(100_000)

        self._tickers_to_ignore: List[str] = ['CLSKW', 'JGGCR']
        self.exchange_codes: List[str] = ['NYS', 'NAS', 'ASE']    

        self._option_trade_flag: bool = True
        self._leverage: int = 5
        self._quantile: int = 5
        self._period: int = 12
        self._holding_period: int = 24  # weeks
        self._short_count: int = 50

        self._ipo_dates: Dict[Symbol, datetime] = {}
    
        self._trade_manager: data_tools.TradeManager = data_tools.TradeManager(self, 0, self._short_count, self._holding_period)

        market: Symbol = self.add_equity('SPY', Resolution.DAILY).Symbol

        self.universe_settings.leverage = self._leverage
        self.universe_settings.resolution = Resolution.DAILY
        self.add_universe(EODHDUpcomingIPOs, self.ipo_selection_filter)
        self.settings.daily_precise_end_time = False
        self.settings.minimum_order_margin_portfolio_percentage = 0.

        self.schedule.on(
            self.date_rules.week_start(market),
            self.time_rules.after_market_open(market),
            self.selection
        )

    def ipo_selection_filter(self, ipos: List[EODHDUpcomingIPOs]) -> List[Symbol]:
        # Save upcoming IPOs.
        for ipo_data in ipos:
            if ipo_data.symbol not in self._ipo_dates:
                if ipo_data.ipo_date and ipo_data.symbol.value not in self._tickers_to_ignore:
                    self._ipo_dates[ipo_data.symbol] = (ipo_data.ipo_date + BDay(1)).date()

        return list(self._ipo_dates.keys())

    def on_data(self, slice: Slice) -> None:
        symbols_to_delete: List[Symbol] = []

        # Trade execution.
        for symbol, date in self._ipo_dates.items():
            chain = self.option_chain(symbol, flatten=True).data_frame
            trade_flag: bool = not chain.empty if self._option_trade_flag else self._ipo_dates[symbol] == self.time.date()
            if trade_flag:
                if date > self.time.date():
                    continue
                if slice.contains_key(symbol) and slice[symbol]:
                    self._trade_manager.add(symbol, False, slice[symbol].close)
                    symbols_to_delete.append(symbol)

        # Remove traded stocks.
        for symbol in symbols_to_delete:
            self._ipo_dates.pop(symbol)
        symbols_to_delete.clear()

    def selection(self) -> None:
        # Hold for n weeks.
        self._trade_manager.try_liquidate()