Overall Statistics
Total Orders
614
Average Win
0.54%
Average Loss
-0.56%
Compounding Annual Return
5.770%
Drawdown
22.200%
Expectancy
-0.014
Start Equity
100000
End Equity
105796.62
Net Profit
5.797%
Sharpe Ratio
0.044
Sortino Ratio
0.054
Probabilistic Sharpe Ratio
21.462%
Loss Rate
50%
Win Rate
50%
Profit-Loss Ratio
0.97
Alpha
0
Beta
0
Annual Standard Deviation
0.171
Annual Variance
0.029
Information Ratio
0.355
Tracking Error
0.171
Treynor Ratio
0
Total Fees
$733.62
Estimated Strategy Capacity
$4300000.00
Lowest Capacity Asset
CYTK SY8OYP5ZLDUT
Portfolio Turnover
6.67%
#region imports
from AlgorithmImports import *
#endregion

class LongShortAlphaModel(AlphaModel):
    def __init__(self, algorithm):
        self.algorithm = algorithm
        self.rebalance_time = datetime.min
    
    def update(self, algorithm, data):
        # Do nothing until next rebalance
        if algorithm.time < self.rebalance_time:
            return []

        insights = []

        # Enter long positions
        for symbol in self.algorithm.long_symbols:
            insights.append(Insight.price(symbol, timedelta(91), InsightDirection.UP))
        # Enter short positions
        for symbol in self.algorithm.short_symbols:
            insights.append(Insight.price(symbol, timedelta(91), InsightDirection.DOWN))

        # Set next rebalance time
        self.rebalance_time = Expiry.EndOfMonth(algorithm.time)

        return insights
#region imports
from AlgorithmImports import *
from selection import ReturnAndEarningsUniverseSelectionModel
from alpha import LongShortAlphaModel
#endregion

class PriceEarningsMomentumAlgorithm(QCAlgorithm):
    '''
    A stock momentum strategy based on quarterly returns and earning growth.
    Paper: http://papers.ssrn.com/sol3/papers.cfm?abstract_id=299107
    Online copy: https://www.trendrating.com/wp-content/uploads/dlm_uploads/2019/03/momentum.pdf
    '''
    def initialize(self):
        self.set_start_date(2023, 3, 1)
        self.set_end_date(2024, 3, 1)
        self.set_cash(100000)            # Set Strategy Cash

        self.set_brokerage_model(BrokerageName.INTERACTIVE_BROKERS_BROKERAGE, AccountType.MARGIN)
        self.set_security_initializer(BrokerageModelSecurityInitializer(self.brokerage_model, FuncSecuritySeeder(self.get_last_known_prices)))

        num_of_coarse = 100          # Number of coarse selected universe
        self.long_symbols = []
        self.short_symbols = []

        self.universe_settings.data_normalization_mode = DataNormalizationMode.RAW
        self.universe_settings.resolution = Resolution.MINUTE          # Resolution setting of universe selection
        self.universe_settings.schedule.on(self.date_rules.month_start())
        self.universe_model = ReturnAndEarningsUniverseSelectionModel(self, num_of_coarse)
        self.set_universe_selection(self.universe_model)   # Coarse and Fine Universe Selection
        
        self.add_alpha(LongShortAlphaModel(self))
        self.set_portfolio_construction(EqualWeightingPortfolioConstructionModel(Expiry.END_OF_WEEK))
        self.add_risk_management(MaximumDrawdownPercentPerSecurity(0.2))
#region imports
from AlgorithmImports import *
#endregion

class ReturnAndEarningsUniverseSelectionModel(FundamentalUniverseSelectionModel):
    """
    This universe selection model refreshes monthly to contain US securities in the asset management industry.
    """
    def __init__(self, algorithm, numOfCoarse):
        self.algorithm = algorithm
        self.num_of_coarse = numOfCoarse
        self.eps_by_symbol = {}           # Contains RollingWindow objects of EPS for every stock

        super().__init__(self.select)

    def select(self, fundamental):
        """
        Coarse universe selection is called each day at midnight.
        
        Input:
         - algorithm
            Algorithm instance running the backtest
         - coarse
            List of CoarseFundamental objects
            
        Returns the symbols that have fundamental data.
        """            
        # Sort the equities (prices > 5) by Dollar Volume descendingly
        # # Pick the top 100 liquid equities as the coarse-selected universe
        selectedByDollarVolume = sorted([x for x in fundamental if x.price > 5 and x.has_fundamental_data], 
                                        key = lambda x: x.dollar_volume, reverse = True)[:self.num_of_coarse]

        symbols = [x.symbol for x in selectedByDollarVolume]

        # Get the quarterly returns for each symbol
        history = self.algorithm.history(symbols, timedelta(91), Resolution.DAILY, data_normalization_mode=DataNormalizationMode.SCALED_RAW)
        history = history.drop_duplicates().close.unstack(level = 0)
        rankByQuarterReturn = self.get_quarterly_return(history)

        # Get the earning growth for each symbol
        rankByEarningGrowth = self.get_earning_growth(selectedByDollarVolume) 

        # Get the sum of rank for each symbol and pick the top ones to long and the bottom ones to short
        rankSumBySymbol = {key: rankByQuarterReturn.get(key, 0) + rankByEarningGrowth.get(key, 0) 
                                for key in set(rankByQuarterReturn) | set(rankByEarningGrowth)}

        # Get 10 symbols to long and short respectively
        sortedDict = sorted(rankSumBySymbol.items(), key = lambda x: x[1], reverse = True)
        self.algorithm.long_symbols = [Symbol(SecurityIdentifier.parse(x[0]), str(x[0]).split(' ')[0]) for x in sortedDict[:5]]
        self.algorithm.short_symbols = [Symbol(SecurityIdentifier.parse(x[0]), str(x[0]).split(' ')[0]) for x in sortedDict[-5:]]
        selected = self.algorithm.long_symbols + self.algorithm.short_symbols

        return selected

    def get_quarterly_return(self, history):
        '''
        Get the rank of securities based on their quarterly return from historical close prices
        Return: dictionary
        '''
        # Get quarterly returns for all symbols
        # (The first row divided by the last row)
        returns = history.iloc[0] / history.iloc[-1]

        # Transform them to dictionary structure
        returns = returns.to_dict()

        # Get the rank of the returns (key: symbol; value: rank)
        # (The symbol with the 1st quarterly return ranks the 1st, etc.)
        ranked = sorted(returns, key = returns.get, reverse = True)
        return {symbol: rank for rank, symbol in enumerate(ranked, 1)}

    def get_earning_growth(self, fine):
        '''
        Get the rank of securities based on their EPS growth
        Return: dictionary
        '''

        # Earning Growth by symbol
        egBySymbol = {}
        for stock in fine:

            # Select the securities with EPS (> 0)
            if stock.earning_reports.basic_eps.three_months == 0:
                continue

            # Add the symbol in the dict if not exist
            if not stock.symbol in self.eps_by_symbol:
                self.eps_by_symbol[stock.symbol] = RollingWindow[float](2)

            # Update the rolling window for each stock
            self.eps_by_symbol[stock.symbol].add(stock.earning_reports.basic_eps.three_months)

            # If the rolling window is ready
            if self.eps_by_symbol[stock.symbol].is_ready:
                rw = self.eps_by_symbol[stock.symbol]
                # Caculate the Earning Growth
                egBySymbol[stock.symbol] = (rw[0] - rw[1]) / rw[1]

        # Get the rank of the Earning Growth
        ranked = sorted(egBySymbol, key = egBySymbol.get, reverse = True)
        return {symbol: rank for rank, symbol in enumerate(ranked, 1)}