Overall Statistics
Total Orders
46
Average Win
2.88%
Average Loss
-2.05%
Compounding Annual Return
49.843%
Drawdown
14.300%
Expectancy
0.778
Start Equity
1000000
End Equity
1501198.86
Net Profit
50.120%
Sharpe Ratio
1.593
Sortino Ratio
2.051
Probabilistic Sharpe Ratio
76.998%
Loss Rate
26%
Win Rate
74%
Profit-Loss Ratio
1.41
Alpha
0
Beta
0
Annual Standard Deviation
0.19
Annual Variance
0.036
Information Ratio
1.875
Tracking Error
0.19
Treynor Ratio
0
Total Fees
$397.49
Estimated Strategy Capacity
$6400000.00
Lowest Capacity Asset
TRV R735QTJ8XC9X
Portfolio Turnover
4.06%
#region imports
from AlgorithmImports import *
#endregion


class MonthlyLongAlphaModel(AlphaModel):
    
    _securities = []
    _month = -1

    def update(self, algorithm: QCAlgorithm, data: Slice) -> List[Insight]:
        # Rebalance monthly when there is quote data, not when we get corporate action
        if data.time.month == self._month or data.quote_bars.count == 0:
            return []

        if not self._securities:
            algorithm.log('MonthlyLongAlphaModel.update: securities collection is empty')
            return []

        self._month = data.time.month

        # Emit insights
        return [Insight.price(security.symbol, Expiry.EndOfMonth, InsightDirection.UP) 
            for security in self._securities if security.symbol in data.quote_bars and security.price]

    def on_securities_changed(self, algorithm: QCAlgorithm, changes: SecurityChanges) -> None:
        for security in changes.added_securities:            
            self._securities.append(security)

        for security in changes.removed_securities:
            if security in self._securities:
                self._securities.remove(security)
#region imports
from AlgorithmImports import *

from universe import TopStandardizedUnexpectedEarningsUniverseSelectionModel
from alpha import MonthlyLongAlphaModel
#endregion


class StandardizedUnexpectedEarningsAlgorithm(QCAlgorithm):
    '''Step 1. Calculate the change in quarterly EPS from its value four quarters ago
       Step 2. Calculate the st dev of this change over the prior eight quarters
       Step 3. Get standardized unexpected earnings (SUE) from dividing results of step 1 by step 2
       Step 4. Each month, sort universe by SUE and long the top quantile
       
       Reference:
       [1] Foster, Olsen and Shevlin, 1984, Earnings Releases, Anomalies, and the Behavior of Security Returns,
           The Accounting Review. URL: https://www.jstor.org/stable/pdf/247321.pdf?casa_token=KHX3qwnGgTMAAAAA:
           ycgY-PzPfQ9uiYzVYeOF6yRDaNcRkL6IhLmRJuFpI6iWxsXJgB2BcM6ylmjy-g6xv-PYbXySJ_VxDpFETxw1PtKGUi1d91ce-h-V6CaL_SAAB56GZRQ
       [2] Hou, Xue and Zhang, 2018, Replicating Anomalies, Review of Financial Studies,
           URL: http://theinvestmentcapm.com/HouXueZhang2019RFS.pdf
    '''

    _undesired_symbols_from_previous_deployment = []
    _checked_symbols_from_previous_deployment = False

    def initialize(self):
        months_count = self.get_parameter("months_count", 36) # Number of months of rolling window object

        # Set backtest start date and warm-up period
        WARM_UP_FOR_LIVE_MODE = self.get_parameter("warm_up_for_live_mode", 1)
        MORNING_STAR_LIVE_MODE_HISTORY = timedelta(30) # US Fundamental Data by Morningstar is limited to the last 30 days
        if self.live_mode:
            self.set_warm_up(MORNING_STAR_LIVE_MODE_HISTORY)

        else: # Backtest mode
            if WARM_UP_FOR_LIVE_MODE: # Need to run a backtest before you can deploy this algorithm live
                now = datetime.now()
                self.set_start_date(now - MORNING_STAR_LIVE_MODE_HISTORY)
                self.set_end_date(now) # The universe selection model will quit 30-days before the current day
            else: # Regular backtest
                self.set_start_date(2023, 3, 1)
                self.set_end_date(2024, 3, 1)
            self.set_warm_up(timedelta(31 * (months_count+1)))
        self.set_cash(1_000_000)

        self.set_brokerage_model(BrokerageName.INTERACTIVE_BROKERS_BROKERAGE, AccountType.MARGIN)
        self.set_security_initializer(BrokerageModelSecurityInitializer(self.brokerage_model, FuncSecuritySeeder(self.get_last_known_prices)))
        self.settings.minimum_order_margin_portfolio_percentage = 0

        self.universe_settings.data_normalization_mode = DataNormalizationMode.RAW
        self.universe_settings.schedule.on(self.date_rules.month_start())
        self.add_universe_selection(TopStandardizedUnexpectedEarningsUniverseSelectionModel(
            self,
            self.universe_settings,
            self.get_parameter("coarse_size", 50),        # Number of stocks to return from Coarse universe selection
            self.get_parameter("top_percent", 0.05),      # Percentage of symbols selected based on SUE sorting
            self.get_parameter("months_eps_change", 12),  # Number of months of lag to calculate eps change
            months_count,
            WARM_UP_FOR_LIVE_MODE
        ))

        self.add_alpha(MonthlyLongAlphaModel())

        self.settings.rebalance_portfolio_on_security_changes = False
        self.settings.rebalance_portfolio_on_insight_changes = False
        self._month = -1
        self.set_portfolio_construction(EqualWeightingPortfolioConstructionModel(self._rebalance_func))

        self.add_risk_management(NullRiskManagementModel())

        self.set_execution(ImmediateExecutionModel())        

    def _rebalance_func(self, time):
        if self._month != self.time.month and not self.is_warming_up and self.current_slice.quote_bars.count > 0:
            self._month = self.time.month
            return time
        return None

    def on_data(self, data):
        # Exit positions that aren't backed by existing insights.
        # If you don't want this behavior, delete this method definition.
        if not self.is_warming_up and not self._checked_symbols_from_previous_deployment:
            for security_holding in self.portfolio.values():
                if not security_holding.invested:
                    continue
                symbol = security_holding.symbol
                if not self.insights.has_active_insights(symbol, self.utc_time):
                    self._undesired_symbols_from_previous_deployment.append(symbol)
            self._checked_symbols_from_previous_deployment = True
        
        for symbol in self._undesired_symbols_from_previous_deployment[:]:
            if self.is_market_open(symbol):
                self.liquidate(symbol, tag="Holding from previous deployment that's no longer desired")
                self._undesired_symbols_from_previous_deployment.remove(symbol)
#region imports
from AlgorithmImports import *
#endregion
# 27/10/2023: - Implement new Scheduled Universe function, and convert to pep8 syntax
#
# 27/10/2023: - Implement new Fundamental Universe Selection Model, merging coarse and fine selections
#
# 08/14/2023: -Reduced warm-up period to 30-days in live mode since the live feed for US fundamentals has a 30-day quota
#             -Reduced the `coarse_size` parameter to 50
#             -Adjusted the algorithm to utilize the ObjectStore to warm-up fundamental data older than 30-days
#             https://www.quantconnect.com/terminal/processCache?request=embedded_backtest_bc50228f65fd60a403e041ad494a58e2.html
#
# 04/15/2024: -Updated to PEP8 style
#             https://www.quantconnect.com/terminal/processCache?request=embedded_backtest_6b6be217580dab839425fc33c1bb181d.html
#region imports
from AlgorithmImports import *
#endregion


class TopStandardizedUnexpectedEarningsUniverseSelectionModel(FundamentalUniverseSelectionModel):
        
    _month = -1
    _hours = None
    _eps_by_symbol = {}   # Contains RollingWindow objects for all stocks
    _new_fine = []        # Contains new symbols selected at Coarse Selection
    
    def __init__(self, algorithm, universe_settings: UniverseSettings = None, coarse_size: int = 50, 
                top_percent: float = 0.05, months_eps_change: int = 12, months_count: int = 36, warm_up_for_live_mode: int = 1) -> None:
        def select(fundamental):
            # If it's a backtest and warm-up is over, save historical fundamental data into the ObjectStore so that you can use it in live mode
            if not algorithm.live_mode and not algorithm.is_warming_up and warm_up_for_live_mode:
                algorithm.object_store.save(self.OBJECT_STORE_MONTH_KEY, str(self._month))
                str_eps_by_symbol = {str(symbol): list(rolling_window)[::-1] for symbol, rolling_window in self._eps_by_symbol.items()}
                save_successful = algorithm.object_store.save(self.OBJECT_STORE_EPS_KEY, json.dumps(str_eps_by_symbol))
                algorithm.quit(f"Done warming up fundamentals. Save was successful: {save_successful}")
                return []
            
            if not self._hours or algorithm.live_mode:
                self._hours = algorithm.market_hours_database.get_entry(Market.USA, "SPY", SecurityType.EQUITY).exchange_hours
            
            selected = [ x for x in fundamental if x.has_fundamental_data and x.price > 5]
            sorted_by_dollar_volume = sorted(selected, key=lambda c: c.dollar_volume, reverse=True)
            self._new_fine = [c.symbol for c in sorted_by_dollar_volume[:coarse_size]]
            # Return all symbols that have appeared in Coarse Selection
            symbols = list( set(self._new_fine).union( set(self._eps_by_symbol.keys()) ) )

            sue_by_symbol = dict()
            
            for stock in fundamental:
                if stock.symbol not in symbols:
                    continue

                ### Save (symbol, rolling window of EPS) pair in dictionary
                if not stock.symbol in self._eps_by_symbol:
                    self._eps_by_symbol[stock.symbol] = RollingWindow[float](months_count)
                # update rolling window for each stock
                self._eps_by_symbol[stock.symbol].add(stock.earning_reports.basic_eps.three_months)
            
                ### Calculate SUE
            
                if stock.symbol in self._new_fine and self._eps_by_symbol[stock.symbol].is_ready:
            
                    # Calculate the EPS change from four quarters ago
                    rw = self._eps_by_symbol[stock.symbol]
                    eps_change = rw[0] - rw[months_eps_change]
                    
                    # Calculate the st dev of EPS change for the prior eight quarters
                    eps_list = list(rw)[::-1]
                    new_eps_list = eps_list[:months_count - months_eps_change:3]
                    old_eps_list = eps_list[months_eps_change::3]
                    eps_std = np.std( [ new_eps - old_eps for new_eps, old_eps in 
                                        zip( new_eps_list, old_eps_list )
                                    ] )
                    
                    # Get Standardized Unexpected Earnings (SUE)
                    sue_by_symbol[stock.symbol] = eps_change / eps_std
            
            # Sort and return the top quantile
            sorted_dict = sorted(sue_by_symbol.items(), key = lambda x: x[1], reverse = True)
            symbols = [ x[0] for x in sorted_dict[:math.ceil( top_percent * len(sorted_dict) )] ]
            return symbols

        super().__init__(select, universe_settings)

        # If it's live mode, load the historical fundamental data from the ObjectStore
        self.OBJECT_STORE_EPS_KEY = f"{algorithm.project_id}/fundamentals-warm-up"
        self.OBJECT_STORE_MONTH_KEY = f"{algorithm.project_id}/month"
        if algorithm.live_mode:
            if not algorithm.object_store.contains_key(self.OBJECT_STORE_EPS_KEY):
                algorithm.quit("No fundamental data in the ObjectStore. Run a backtest before deploying live.")
                return
            self._eps_by_symbol = {}
            for security_id, eps_list in json.loads(algorithm.object_store.read(self.OBJECT_STORE_EPS_KEY)).items():
                window = RollingWindow[float](months_count)
                for x in eps_list:
                    window.add(x)
                self._eps_by_symbol[algorithm.symbol(security_id)] = window
            self._month = int(algorithm.object_store.read(self.OBJECT_STORE_MONTH_KEY))