Overall Statistics
Total Orders
5283
Average Win
0.64%
Average Loss
-0.47%
Compounding Annual Return
10.377%
Drawdown
67.300%
Expectancy
0.227
Start Equity
100000
End Equity
1259149.01
Net Profit
1159.149%
Sharpe Ratio
0.368
Sortino Ratio
0.424
Probabilistic Sharpe Ratio
0.079%
Loss Rate
48%
Win Rate
52%
Profit-Loss Ratio
1.35
Alpha
0.029
Beta
0.78
Annual Standard Deviation
0.173
Annual Variance
0.03
Information Ratio
0.159
Tracking Error
0.125
Treynor Ratio
0.081
Total Fees
$43751.44
Estimated Strategy Capacity
$0
Lowest Capacity Asset
PRTC XJKUBVFC431H
Portfolio Turnover
1.11%
Drawdown Recovery
2047
#region imports
from AlgorithmImports import *
#endregion
import numpy as np
import datetime
from datetime import timedelta, date
from dateutil.relativedelta import relativedelta
import random



class BuildingMagic(QCAlgorithm):

    def Initialize(self):
        self.SetStartDate(2000, 1, 1)    # Set Start Date
        #self.SetEndDate(2011, 12, 31)      # Set End Date
        self.SetCash(100_000)             # Set Cash
        self.spy = self.AddEquity("SPY", Resolution.Hour).Symbol
        self.SetBenchmark(self.spy)

        self.UniverseSettings.Resolution = Resolution.Hour
        self.UniverseSettings.Leverage = 1
        self._universe = self.add_universe(self._fundamental_function)

        ###   VARIABLES   ###
        self.shares_in_portfolio = 50
        self.min_perc = 20
        self.max_perc = 30

        self.buy_list = []
        self.master_dict = {}
        

        ### DATES ###
        self.manual_trading_date = date.fromisoformat('2050-02-12')     # use YYYY-MM-DD format
        self.trading_days = [(self.Time).date()]
        self.trading_dates()
                     


    def _fundamental_function(self, fundamental: List[Fundamental]) -> List[Symbol]:

        if self.portfolio.invested and (self.Time).date() not in self.trading_days:
            return Universe.Unchanged
        
        self.liquidate()
        self.buy_list.clear()
        self.master_dict.clear()
        
        #use fundamental filters here
        filtered = [x for x in fundamental
                    #  if x.CompanyReference.country_id == "USA"
                     ]

        for x in filtered:
            self.master_dict[x] = {}

        self.calc_sharpe_ratio()

        ### RANDOMIZE ###
        self.buy_list = self.values_between_percentiles(self.min_perc, self.max_perc) 
        

        return [x.Symbol for x in self.buy_list]

        

    def OnData(self, slice: Slice) -> None:

        if self.portfolio.invested and (self.Time).date() not in self.trading_days:
            return
        
        if len(self.buy_list) < self.shares_in_portfolio /2:
            return

        if self.Securities[self.spy].Exchange.ExchangeOpen:
            self.sell_and_buy(self.buy_list)      
            self.trading_days.pop(0)





    def values_between_percentiles(self, min_percentile, max_percentile):
        import numpy as np
        import random

        momentum_list = []
        for x in list(self.master_dict.keys()):
            if "Momentum" not in self.master_dict[x] or np.isnan(self.master_dict[x]["Momentum"]):
                del self.master_dict[x]
                continue
            momentum_list.append([x, self.master_dict[x]["Momentum"]])
        momentum_list.sort(key=lambda item: item[1], reverse=True)  # descending order

        if not 0 <= min_percentile <= 100 or not 0 <= max_percentile <= 100:
            raise ValueError("Percentiles must be between 0 and 100")
        if min_percentile > max_percentile:
            raise ValueError("min_percentile should be less than or equal to max_percentile")
        if not momentum_list:
            return []

        momentum_values = [item[1] for item in momentum_list]  # extract numeric values
        min_val = np.percentile(momentum_values, min_percentile)
        max_val = np.percentile(momentum_values, max_percentile)

        # Filter stocks whose momentum falls between min_val and max_val inclusive
        result = [item[0] for item in momentum_list if min_val <= item[1] <= max_val]
        
        if len(result) > self.shares_in_portfolio * 2:
            return random.sample(result, int(self.shares_in_portfolio * 1.32))

        return result


    def get_random_fundamentals(self, fundamentals_list, count):
        # Make sure count doesn't exceed the list length
        count = min(count, len(fundamentals_list))
        return random.sample(fundamentals_list, count)
    


    def calc_sharpe_ratio(self):
        '''Calculate the sharpe ratio of stocks for the last year, excluding the last month, daily resolution'''
        
        # Calculate date range: 12 months ago to 1 month ago
        end_date = self.Time - relativedelta(months=1)  # Exactly 1 month ago
        start_date = self.Time - relativedelta(months=12)  # Exactly 12 months ago
        
        to_delete = []
        
        for x in list(self.master_dict.keys()):
            # Request history exactly for the desired period (start inclusive, end exclusive in QC)
            history = self.History(x.symbol, start_date, end_date + timedelta(days=1), Resolution.Daily)
            if 'close' not in history or history.empty:
                to_delete.append(x)
                continue
            
            closes = history['close'].dropna()
            if len(closes) < 220:  # Approx trading days for 11 months (252 * 11/12 ≈ 231, using 220 as safe threshold)
                to_delete.append(x)
                continue
            
            # Calculate total return using first and last price
            first_close = closes.iloc[0]
            last_close = closes.iloc[-1]
            total_return = (last_close / first_close) - 1 if first_close != 0 else 0.0
            
            # Use log returns for more precise standard deviation calculation
            log_returns = np.log(closes / closes.shift(1)).dropna()
            if log_returns.empty or total_return == 0.0:
                to_delete.append(x)
                continue
            
            n_days = len(log_returns)
            period_std = log_returns.std() * np.sqrt(n_days) if n_days > 0 else 0.0
            
            sharpe_ratio = total_return / period_std if period_std != 0 else 0.0

            if sharpe_ratio == 0.0:
                to_delete.append(x)
                continue

            self.master_dict[x]["Momentum"] = round(sharpe_ratio, 3)
        
        for x in to_delete:
            del self.master_dict[x]
        

    def sell_and_buy(self, buy_list):
        
        self.liquidate()
        
        if len(buy_list) == 0:
            return
        holding = round((1/self.shares_in_portfolio),3)
        for symbol in buy_list:
            if  self.securities[symbol.symbol].is_tradable:
                self.SetHoldings(symbol.symbol, holding)
                open_positions = [x.Symbol for x in self.Portfolio.Securities.Values if x.Invested]
                if len(open_positions) >= self.shares_in_portfolio:
                    break

        open_positions = [x.Symbol for x in self.Portfolio.Securities.Values if x.Invested]
        self.log(f'there are {len(open_positions)} open positions')
   

    def trading_dates(self):

        self.trading_days = self.trading_days = [
                date(2025, 8, 15),  # Q4
                # 2000
                date(2000, 1, 3),   # Q1
                date(2000, 7, 3),   # Q3
                # 2001
                date(2001, 1, 2),   # Q1
                date(2001, 7, 2),   # Q3
                # 2002
                date(2002, 1, 2),   # Q1
                date(2002, 7, 1),   # Q3
                # 2003
                date(2003, 1, 2),   # Q1
                date(2003, 7, 1),   # Q3
                # 2004
                date(2004, 1, 2),   # Q1
                date(2004, 7, 1),   # Q3
                # 2005
                date(2005, 1, 3),   # Q1
                date(2005, 7, 1),   # Q3
                # 2006
                date(2006, 1, 3),   # Q1
                date(2006, 7, 3),   # Q3
                # 2007
                date(2007, 1, 2),   # Q1
                date(2007, 7, 2),   # Q3
                # 2008
                date(2008, 1, 2),   # Q1
                date(2008, 7, 1),   # Q3
                # 2009
                date(2009, 1, 2),   # Q1
                date(2009, 7, 1),   # Q3
                # 2010
                date(2010, 1, 4),   # Q1
                date(2010, 7, 1),   # Q3
                # 2011
                date(2011, 1, 3),   # Q1
                date(2011, 7, 1),   # Q3
                # 2012
                date(2012, 1, 3),   # Q1
                date(2012, 7, 2),   # Q3
                # 2013
                date(2013, 1, 2),   # Q1
                date(2013, 7, 1),   # Q3
                # 2014
                date(2014, 1, 2),   # Q1
                date(2014, 7, 1),   # Q3
                # 2015
                date(2015, 1, 2),   # Q1
                date(2015, 7, 1),   # Q3
                # 2016
                date(2016, 1, 4),   # Q1
                date(2016, 7, 1),   # Q3
                # 2017
                date(2017, 1, 3),   # Q1
                date(2017, 7, 3),   # Q3
                # 2018
                date(2018, 1, 2),   # Q1
                date(2018, 7, 2),   # Q3
                # 2019
                date(2019, 1, 2),   # Q1
                date(2019, 7, 1),   # Q3
                # 2020
                date(2020, 1, 2),   # Q1
                date(2020, 7, 1),   # Q3
                # 2021
                date(2021, 1, 4),   # Q1
                date(2021, 7, 1),   # Q3
                # 2022
                date(2022, 1, 3),   # Q1
                date(2022, 7, 1),   # Q3
                # 2023
                date(2023, 1, 3),   # Q1
                date(2023, 7, 3),   # Q3
                # 2024
                date(2024, 1, 2),   # Q1
                date(2024, 7, 1),   # Q3
                # 2025
                date(2025, 1, 2),   # Q1
                date(2025, 7, 1),   # Q3
                # 2026
                date(2026, 1, 2),   # Q1
                date(2026, 7, 1),   # Q3
            ]