Overall Statistics
Total Trades
247
Average Win
1.54%
Average Loss
-1.26%
Compounding Annual Return
6.333%
Drawdown
31.500%
Expectancy
0.359
Net Profit
51.744%
Sharpe Ratio
0.329
Probabilistic Sharpe Ratio
1.687%
Loss Rate
39%
Win Rate
61%
Profit-Loss Ratio
1.23
Alpha
0.025
Beta
0.592
Annual Standard Deviation
0.189
Annual Variance
0.036
Information Ratio
-0.008
Tracking Error
0.168
Treynor Ratio
0.105
Total Fees
$2879.10
Estimated Strategy Capacity
$16000.00
Lowest Capacity Asset
MMMB XQ652EL0JWPX
'''
This is a backtest of the Magic Formula as described by investor Joel Greenblatt
in his books The Little Book That Beats the Market (2005) and 
The Little Book That Still Beats the Market (2010).

This algorithm is designed by Group 23 for WQU MSc Financial Engineering Capstone Project:
Hannes Rohregger
Guillermo Huguet Serra 
Jarrett Oh Jia Cheng

2022/10/18
'''

# Import all the functionality needed to run algorithms
from AlgorithmImports import *

# Define a trading algorithm that is a subclass of QCAlgorithm
class MagicFormula(QCAlgorithm):
    '''
    This method is the entry point of your algorithm where you define a series of settings.
    LEAN only calls this method one time, at the start of your algorithm.
    '''
    def Initialize(self) -> None:
        self.Debug(f'--- Initializing Algorithm ----')
        # Set start and end dates
        self.SetStartDate(2016, 1, 1)
        self.SetEndDate(2022, 10, 15)
        
        # Set warmup period for data loading
        self.SetWarmUp(100)

        # Set the starting cash balance to $1m USD
        self.SetCash(1000000)

        self.SetBenchmark("QVAL")
        
        # Daily resolution is sufficient
        self.UniverseSettings.Resolution = Resolution.Daily

        # Simulation of having brokerage account with IBKR.
        # Fees are InteractiveBrokersFee
        self.SetBrokerageModel(BrokerageName.InteractiveBrokersBrokerage, AccountType.Cash)

        # Algorithm Variables
        self.lastMonth = -1
        self.purchased_securities = {}

        # Percent holding calculations
        self.NumberSecurtitiesPortfolio = 24
        self.MonthsToKeepSecurities = 12
        self.NumberSecuritiesPerMonth = int(self.NumberSecurtitiesPortfolio / self.MonthsToKeepSecurities)
        self.percent_holding = round((self.NumberSecuritiesPerMonth/self.NumberSecurtitiesPortfolio)/self.NumberSecuritiesPerMonth,3)

        # Adding universe pipeline to algorithm
        self.AddUniverse(self.CoarseFilterFunction, self.FineFundamentalFunction)

    def CoarseFilterFunction(self, coarse: List[CoarseFundamental]) -> List[Symbol]:
        '''
        This is first level filtering
        If it is a new month:
        1) Make sure that the stock has fundamental data
        2) Sort by trading dollar volume
        3) Pass the list to FineFundamentalFunction
        '''

        if self.IsWarmingUp: 
            return Universe.Unchanged
       
        self.month = self.Time.month
        if self.month == self.lastMonth:
            return Universe.Unchanged

        self.Debug(f'--------{self.Time}------------')
        self.Debug(f'Portfolio value: {self.Portfolio.TotalPortfolioValue}')
      
        self.Debug(f'Length of self.Portfolio {len(self.Portfolio)}')

        self.lastMonth = self.month
      
        filtered = [x for x in coarse if x.HasFundamentalData]
        dollar_sorted = sorted(filtered, key=lambda x: x.DollarVolume, reverse=True)

        return [x.Symbol for x in dollar_sorted]

    def FineFundamentalFunction(self, fine: List[FineFundamental]) -> List[Symbol]:
        '''
        This is second layer filtering
        Further filtering:
        1) Marketcap > $50m
        2) USA companies in NYSE and NASDAQ
        3) Exclude Financials and Utility stocks
        4) Filter by Return on Assets > 25%
        5) Sort by Earnings Yield
        6) Sort by Return on Assets
        '''
        
        # Filter out ADRs and limit to Nasdaq and NYSE
        filtered_marketcap = [x for x in fine if x.MarketCap > 50e6]

        filtered_us = [x for x in filtered_marketcap if x.CompanyReference.CountryId == "USA"
                                        and (x.CompanyReference.PrimaryExchangeID == "NYS" or x.CompanyReference.PrimaryExchangeID == "NAS")]

        # Exclude Financial or Utility stocks
        # As their accounting is different
        # https://www.quantconnect.com/datasets/morning-star-us-fundamentals/documentation
        filtered_indu = [x for x in filtered_us if (x.AssetClassification.MorningstarSectorCode != MorningstarSectorCode.Utilities)
                            and (x.AssetClassification.MorningstarSectorCode != MorningstarSectorCode.FinancialServices)]

        # REF: https://www.quantconnect.com/docs/v2/writing-algorithms/universes/equity

        # Book
        filtered_ROA = [x for x in filtered_indu if x.ValuationRatios.ForwardROA > 0.25]

        # Earnings Yield
        # The net repurchase of shares outstanding over the market capital of the company. It is a measure of shareholder return.
        sorted_EVToEBITDA = sorted(filtered_ROA, key=lambda x: x.ValuationRatios.EVToEBITDA , reverse=True)

        # Return on Capital/Assets
        # 2 Years Forward Estimated EPS / Adjusted Close Price
        sorted_ROA = sorted(sorted_EVToEBITDA, key=lambda x: x.ValuationRatios.ForwardROA, reverse=False) 

        # Remove any securities which we are already holding
        not_holding = [f.Symbol for f in sorted_ROA if f.Symbol not in self.purchased_securities]
        
        final_list = not_holding[:self.NumberSecuritiesPerMonth]

        return final_list

    def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
        '''
        This is run every month.
        Portfolio is updated:
        1) 2 stocks are purchased
        2) Stocks that have been held for 12 months are sold.
        '''
        self.Debug('-----')
        self.Debug(f'running OnSecuritiesChanged')

        self.sell_securities(self.month)
        
        for security in changes.AddedSecurities:

            self.Debug(f"{self.Time}: Buying {security.Symbol} for holding percent {self.percent_holding} Price: {security.Price}")

            self.SetHoldings(security.Symbol, self.percent_holding, False)
            self.purchased_securities[security.Symbol] = self.Time.month

    def OnOrderEvent(self, orderEvent: OrderEvent) -> None:
        ''' For analysis purposes '''

        order = self.Transactions.GetOrderById(orderEvent.OrderId)
        if orderEvent.Status == OrderStatus.Filled:
            self.Debug(f"Order Event: {self.Time}: {order.Type}: {orderEvent}")

    def sell_securities(self, month) -> None:
        '''
        Sell function. This checks which stocks have been held for 12 months and closes these.
        '''
        self.Debug('-----')
        self.Debug(f'sell_securites function. purchased_securities : {len(self.purchased_securities)}')

        # For analysis purposes:
        total_value = 0
        for kvp in self.Portfolio:
            security_holding = kvp.Value
            symbol = security_holding.Symbol.Value
            # Quantity of the security held
            quantity = security_holding.Quantity
            # Average price of the security holdings 
            price = security_holding.AveragePrice
            total_value += quantity*price
        self.Debug(f'Total value of existing holdings: {total_value}')

        to_pop = []
        for key, value in self.purchased_securities.items():
            if value == month:
                # It's been 12 months, time to sell
                self.Debug(f"removed security: {key} from portfolio")
                self.Liquidate(key)
                to_pop.append(key)
        
        for security in to_pop:
            self.purchased_securities.pop(security)      

    def OnEndOfAlgorithm(self) -> None:
        self.Debug("Algorithm done")