Overall Statistics
Total Orders
783
Average Win
0.45%
Average Loss
-0.36%
Compounding Annual Return
13.597%
Drawdown
33.900%
Expectancy
0.417
Start Equity
1000000
End Equity
1665785.75
Net Profit
66.579%
Sharpe Ratio
0.485
Sortino Ratio
0.624
Probabilistic Sharpe Ratio
15.318%
Loss Rate
38%
Win Rate
62%
Profit-Loss Ratio
1.27
Alpha
0.123
Beta
0.661
Annual Standard Deviation
0.18
Annual Variance
0.032
Information Ratio
0.961
Tracking Error
0.147
Treynor Ratio
0.132
Total Fees
$64154.75
Estimated Strategy Capacity
$3200000.00
Lowest Capacity Asset
TSN R735QTJ8XC9X
Portfolio Turnover
1.51%
# region imports
from AlgorithmImports import *
# endregion

class FundamentalBacktester1(QCAlgorithm):

    def Initialize(self):
        self.SetStartDate(1999, 1, 1)
        self.SetEndDate(2003, 1, 1)
        self.SetCash(1000000) # Set initial cash balance (portfolio size)

        # Universe settings
        self.UniverseSettings.Resolution = Resolution.Daily # Set resolution to daily
        self.settings.minimum_order_margin_portfolio_percentage = 0

        # portolio constuction model
        self.SetPortfolioConstruction(EqualWeightingPortfolioConstructionModel(lambda time: None)) # Use equal weighted portfolio construction module
        self.Settings.RebalancePortfolioOnInsightChanges = False # means that the portfoli constuction module above will not rebalance portfolio when insights change
        self.Settings.RebalancePortfolioOnSecurityChanges = True # means that the portfoli constuction module above will rebalance portfolio when securities change


        # Set universe selection model
        self.SetUniverseSelection(FineFundamentalUniverseSelectionModel(self.CoarseFilter, self.FineFilter)) # sets the stock universe to whaterver come out of the course & then fine filter fuctions

        # set benchmark
        self.spy = self.AddEquity("SPY", Resolution.Daily).Symbol # call spy (S&P500) to save in self.spy
        self.SetBenchmark("SPY") # sets spy as the benchmark (aplha determined by outperformance to this)

        # portfolio Rebalance scedule
        self.Schedule.On(self.DateRules.MonthStart("SPY"), self.TimeRules.AfterMarketOpen("SPY", 120), self.Rebalance)  # call Rebalance function at the start of the month 2hr after market open - base market hours on SPY

        #initalise variables
        self.num_coarse = 250  # number of stocks that will be returned from coarse selection filter (used in fuction)
        self.num_fine = 30    # number of stocks that will be returned from fine selection filter (used in fuction)
        self.day_counter = 0   # counts number of days (number of time on_data is called)
        self.filtered_stocks = []  # output of FineFilter function
        self.PV = []  # stores currently held symbols
        self.charts = []  # stores symbols to plot

        self.nextday = False # checker to skip a day before order (so we dont buy based on fundmental data released that day - if rebalence is on a data_release day)
        self.monthly_rebalance = False # checker to see if rebalence is due
        self.rebalanced = False # checker to see if rebalence has happended
        self.no_data_day = False # checker to skip insight-changes / orders-created in on_data on non-market days

        self.blacklist = ['CNI','BCE'] # stores symbols that have data issues (exclude these in coarse filter)

        # Set a custom security initializer
        self.SetSecurityInitializer(self.CustomSecurityInitializer)


    # Set a custom security initializer
    def CustomSecurityInitializer(self, security): 
        security.SetFeeModel(CustomPercentageFeeModel()) # call class containing fee model

    
    # coarse universe filter fuction (runs every day at midnight)
    #filters stock universe, returns 500 sybmols with the largest market cap, that have available fundamdal data and dollar volume > $5m
    def CoarseFilter(self, coarse):
        if self.nextday:
            return Universe.Unchanged

        # If the rebalance flag is not set, do not change the universe
        if not self.monthly_rebalance:
            self.rebalanced = False
            return Universe.Unchanged 

        self.rebalanced = True
        self.monthly_rebalance = False

        filtered_coarse = sorted([x for x in coarse if x.HasFundamentalData and x.Price > 0 and x.valuation_ratios.pe_ratio >0 and x.DollarVolume > 5000000],key=lambda x: x.market_cap, reverse=True)   # Filter out all stocks that have no fundamental data or DollarVolume <= $5M, sort by market cap - large to small
        
        
        # send list of filtered coarse stocks to debugger inc number and pe_ratio
        debug_output = ""
        for i, coarse_obj in enumerate(filtered_coarse[:self.num_coarse], start=1):
            symbol_name = coarse_obj.Symbol.Value  # Ensure to get only the symbol string
            market_cap = coarse_obj.market_cap
            debug_output += f"{i}. {symbol_name}: Market Cap = {market_cap}\n"
        self.Debug(f"Course Filtered Symbols:\n{debug_output}  ")  # Log the selected symbols with P/E ratios

        return [x.Symbol for x in filtered_coarse[:self.num_coarse]]  # Return the top 'num_coarse' symbols
    
    # fine universe filter fuction, (runs every day at midnight)
    #filters Course filter output (500 sybmols) returns 50 sybmols with lowest PE Ratio
    def FineFilter(self, fine):
        # Filter and sort stocks by P/E ratio
        self.filtered_stocks = []

        #selected_fine = [x for x in fine if x.valuation_ratios.pe_ratio > 0]

        filtered_fine = sorted([x for x in fine], key=lambda x: x.valuation_ratios.pe_ratio)  # Sort by P/E ratio
        
        self.filtered_stocks = [x.Symbol for x in filtered_fine[:self.num_fine]]  # Select the top 50 'num_fine' symbols

        # send list of filtered fine stocks to debugger inc number and pe_ratio
        debug_output = ""
        for i, fine_obj in enumerate(filtered_fine[:self.num_fine], start=1):
            symbol_name = fine_obj.Symbol.Value  # Ensure to get only the symbol string
            pe_ratio = fine_obj.valuation_ratios.pe_ratio
            debug_output += f"{i}. {symbol_name}: PE Ratio = {pe_ratio}\n"

        self.Debug(f"Fine Filtered Symbols\n{debug_output}  ")  # Log the selected symbols with P/E ratios

        return self.filtered_stocks  # Return the selected symbols



    # OnData fuction (runs every day at midnight)
    def OnData(self, data):
        self.day_counter += 1

        if "SPY" in data:
            self.no_data_day = False
            spy_data = data["SPY"]
            self.Plot("SPY", "SPY", spy_data.Open, spy_data.High, spy_data.Low, spy_data.Close)
        else:
            self.no_data_day = True

        # returns out of function if not just rebalenced
        if not self.rebalanced:
            return

        if self.no_data_day:
            self.nextday = True
            self.Debug("No SPY data: skip a day "+str(self.time) +"   day "+str(self.day_counter))
            return

        # If the next day = 1 skip till the next bar/day/on_data call (means that we always buy stock 1 day after getting buy signal)
        if not self.nextday:
            self.nextday = True
            self.Debug("skip a day "+str(self.time) +"   day "+str(self.day_counter))
            return
        self.Debug("next day "+str(self.time))
        
        self.nextday = False
        insights = []
        self.open_counter = 0   # counts opened symbols
        self.close_counter = 0   # counts closed symbols
       
        # Close insights for symbols no longer in self.filtered_stocks
        for symbol in list(self.PV):
            if symbol not in self.filtered_stocks:
                if not data.ContainsKey(symbol): # skip if no stock data
                    self.Debug(f"Skipping sell: {symbol} as it does not have data {self.time}") # log skip 
                    continue
                insights.append(Insight.Price(symbol, timedelta(days=7560), InsightDirection.Flat)) # create insight to close symbol
                self.PV.remove(symbol) # remove symbol from PV list
                self.close_counter += 1 # add 1 to number of closed postions this rebalence
                if data[symbol] is not None: # should not need this if/else (one stock trade only is returning an error)
                    close_price = data[symbol].Close
                    self.Debug(f"Close {symbol} @ {close_price}") # log symbol close
                else:
                    self.Debug(f"Close {symbol} @ could not retrieve price") # log symbol close (no price)

        
        # Add new open insights for symbols not in self.PV (open)
        for symbol in self.filtered_stocks:
            if symbol not in self.PV:
                if not data.ContainsKey(symbol):  # skip if no stock data
                    self.Debug(f"Skipping Buy: {symbol} as it does not have data {self.time}") # log skip
                    continue
                insights.append(Insight.Price(symbol, timedelta(days=7560), InsightDirection.Up))  # create insight to open symbol
                self.PV.append(symbol)  # add symbol from PV list
                self.open_counter += 1  # add 1 to number of opended postions this rebalence
                if data[symbol] is not None:  # should not need this if/else (one stock trade only is returning an error)
                    open_price = data[symbol].Close
                    self.Debug(f"Open {symbol} @ {open_price}")  # log symbol open
                else:
                    self.Debug(f"Open {symbol} @ could not retrieve price")  # log symbol open (no price)

        self.Debug("Currently holding "+str(len(self.PV))+" Securities: Opened "+str(self.open_counter)+" Closed "+str(self.close_counter))  # log number of opened, closed, and held securities

        self.EmitInsights(insights)

    
    # this is called according to Schedule.On in Initialize    
    def Rebalance(self):
        self.monthly_rebalance = True



class CustomPercentageFeeModel(FeeModel): # class called each trade, here we set the fee model (0.2% of trade value)
    def GetOrderFee(self, parameters):
        # Calculate fee as 0.2% of the order value
        order = parameters.Order
        value = order.AbsoluteQuantity * parameters.Security.Price
        fee = value * 0.002  # 0.2% of order value
        return OrderFee(CashAmount(fee, "USD"))




### spare/unused code:
'''

        #Plot
        for symbol in self.charts: # plot all charts that we have held during strategy
            if not data.ContainsKey(symbol):
                self.add_equity(symbol, Resolution.DAILY)
            self.Plot(symbol.Value, "Price", self.Securities[symbol].Price)



        insights = []
        # Close insights for previously skipped symbols
        for symbol in list(self.close_list):
            if data.ContainsKey(symbol): # close if stock now has data
                insights.append(Insight.Price(symbol, timedelta(days=7560), InsightDirection.Flat))
                self.close_list.remove(symbol)
                self.Debug(f"Closed {symbol} on {self.time} (previously skipped)")

        # Open insights for previously skipped symbols
        for symbol in list(self.open_list):
            if data.ContainsKey(symbol): # open if stock now has data
                insights.append(Insight.Price(symbol, timedelta(days=7560), InsightDirection.Up))
                self.open_list.remove(symbol)
                self.Debug(f"Opened {symbol} on {self.time} (previously skipped)")


                # create plot for each symbol opened (for debugging)
                if symbol.Value not in self.charts:
                    self.charts.append(symbol)
                    chart = Chart(symbol.Value)
                    chart.AddSeries(Series("Price", SeriesType.Line, 0))
                    self.AddChart(chart)
        
        
        
'''