Overall Statistics
Total Orders
15780
Average Win
0.19%
Average Loss
-0.35%
Compounding Annual Return
12.808%
Drawdown
65.700%
Expectancy
0.096
Start Equity
100000
End Equity
1933316.69
Net Profit
1833.317%
Sharpe Ratio
0.423
Sortino Ratio
0.479
Probabilistic Sharpe Ratio
0.148%
Loss Rate
29%
Win Rate
71%
Profit-Loss Ratio
0.54
Alpha
0.047
Beta
1.006
Annual Standard Deviation
0.212
Annual Variance
0.045
Information Ratio
0.338
Tracking Error
0.139
Treynor Ratio
0.089
Total Fees
$7164.43
Estimated Strategy Capacity
$15000000.00
Lowest Capacity Asset
DKS SIWB68YAO4TH
Portfolio Turnover
4.05%
#region imports
from AlgorithmImports import *
#endregion

# Your New Python File
class SymbolData():
    def __init__(self, period: int, volatility_period: int, rolling_flag: bool) -> None:
        self._rolling_flag: bool = rolling_flag
        self._period: int = period
        self._daily_price: RollingWindow = RollingWindow[float](volatility_period)
        if self._rolling_flag:
            self._price: RollingWindow = RollingWindow[float](period)
        else:
            self._price: List[float] = []

    def update_daily_price(self, price: float) -> None:
        self._daily_price.add(price)
        
    def update_price(self, price: float) -> None:
        if self._rolling_flag:
            self._price.add(price)
        else:
            self._price.insert(0, price)

    def get_daily_returns(self) -> np.ndarray:
        daily_returns: np.ndarray = pd.Series(list(self._daily_price)[::-1]).pct_change().values[1:]
        return daily_returns

    def get_performance(self) -> float:
        return self._price[0] / list(self._price)[-1] - 1

    def reset(self) -> None:
        self._price = self._price[:1]

    def is_ready(self) -> bool:
        if self._rolling_flag:
            return self._price.is_ready and self._daily_price.is_ready
        else:
            return len(self._price) > 1 and self._daily_price.is_ready

    def get_inverse_vol(self) -> float:
        daily_returns: np.ndarray = self.get_daily_returns()
        return 1 / (np.std(daily_returns) * np.sqrt(3*21))

# Custom fee model
class CustomFeeModel(FeeModel):
    def GetOrderFee(self, parameters: OrderFeeParameters) -> OrderFee:
        fee: float = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
        return OrderFee(CashAmount(fee, "USD"))
# region imports
from AlgorithmImports import *
import data_tools
from dateutil.relativedelta import relativedelta
from typing import List, Dict, Set
from pandas.core.frame import DataFrame
from pandas.core.series import Series
# endregion

class RebalancePeriod(Enum):
    MONTHLY = 1
    BIWEEKLY = 2
    WEEKLY = 3

class Metatron005(QCAlgorithm):

    def initialize(self) -> None:
        self.set_start_date(2000, 1, 1)
        self.set_cash(100_000)


        # PARAMS:
        self.rebalance_period: RebalancePeriod = RebalancePeriod.BIWEEKLY

        # True -> 12 month rolling performance
        # False -> YTD performance
        self.rolling_perf_flag: bool = False

        # True -> Inversed volatility weighting
        # False -> Equally weighting
        self.inversed_volatily_weighting_flag: bool = False

        # pick max sector percentage of total traded stock count
        self.sector_max_percentage: float = 0.20

        # universe count by market cap
        self.fundamental_count: int = 500

        # alpha chart
        self.calculate_alpha: bool = False


        self.universe_settings.leverage = 5
        self.tickers_to_ignore: List[str] = ['EGAN', 'CR', 'PATH', 'ILIF', 'CRW']
        self.exchange_codes: List[str] = ['NYS', 'NAS', 'ASE']    

        self.top_count: int = 20
        self.month_period: int = 11
        self.week_period: int = 51
        self.rolling_period: int = 12
        self.inverse_volatility_period: int = 3 * 21

        self.fundamental_sorting_key = lambda x: x.market_cap

        self.data: Dict[Symbol, data_tools.SymbolData] = {}
        self.sector_manager: Dict[int, int] = {}
        self.long: List[Symbol] = []

        years_of_interest: List[int] = [2008, 2009, 2021]
        self.traded_stocks_by_year: Dict[int, Set] = {
            i: set() for i in years_of_interest
        }

        self.market: Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol

        self.selection_flag: bool = False
        self.universe_settings.resolution = Resolution.DAILY
        self.add_universe(self.fundamental_selection_function)
        self.settings.minimum_order_margin_portfolio_percentage = 0.
        self.settings.daily_precise_end_time = False

        if self.rebalance_period == RebalancePeriod.MONTHLY:
            self.schedule.on(self.date_rules.month_start(self.market),
                            self.time_rules.after_market_open(self.market),
                            self.selection)
        else:
            self.schedule.on(self.date_rules.every_day(self.market),
                            self.time_rules.after_market_open(self.market),
                            self.selection)
            self.counter: int = 0
            self.current_week: int = -1
        self.curr_year: int = -1

    def on_securities_changed(self, changes: SecurityChanges) -> None:
        for security in changes.added_securities:
            security.set_fee_model(data_tools.CustomFeeModel())

    def fundamental_selection_function(self, fundamental: List[Fundamental]) -> List[Symbol]:
        # update data
        for stock in fundamental:
            symbol: Symbol = stock.symbol
            
            if symbol in self.data:
                self.data[symbol].update_daily_price(stock.adjusted_price)
                if self.selection_flag:
                    self.data[symbol].update_price(stock.adjusted_price)

        if not self.selection_flag:
            return Universe.UNCHANGED

        selected: List[Fundamental] = [
            x for x in fundamental 
            if x.SecurityReference.ExchangeId in self.exchange_codes
            and x.Market == 'usa'
            and x.symbol.value not in self.tickers_to_ignore
        ]
        if len(selected) > self.fundamental_count:
            selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]

        stock_performance: Dict[Symbol, float] = {}

        # price warmup
        for stock in selected:
            symbol: Symbol = stock.symbol
            
            if stock.asset_classification.morningstar_sector_code not in self.sector_manager:
                self.sector_manager[stock.asset_classification.morningstar_sector_code] = self.top_count * self.sector_max_percentage

            if symbol not in self.data:
                self.data[symbol] = data_tools.SymbolData(self.rolling_period, self.inverse_volatility_period, self.rolling_perf_flag)

                if self.rolling_perf_flag:
                    history: DataFrame = self.history(symbol, start=self.time.date() - relativedelta(months=self.rolling_period), end=self.time.date())
                else:
                    if self.rebalance_period == RebalancePeriod.MONTHLY:
                        lookback: int = self.time.month - 1 if self.time.month != 1 else self.time.month + self.month_period
                        history: DataFrame = self.history(symbol, start=self.time.date() - relativedelta(months=lookback), end=self.time.date())
                    else:
                        lookback: int = self.time.isocalendar().week - 1 if self.time.isocalendar().week != 1 else self.time.isocalendar().week + self.week_period
                        history: DataFrame = self.history(symbol, start=self.time.date() - relativedelta(weeks=lookback), end=self.time.date())

                if history.empty:
                    self.log(f"Not enough data for {symbol} yet.")
                    continue
                data: Series = history.loc[symbol]
                data_periodically: Series = data.groupby(pd.Grouper(freq='MS')).first() if self.rebalance_period == RebalancePeriod.MONTHLY else data.groupby(pd.Grouper(freq='W')).first()
                for time, row in data_periodically.iterrows():
                    self.data[symbol].update_price(row.close)
                for time, row in data.iterrows():
                    self.data[symbol].update_daily_price(row.close)
                
            if self.data[symbol].is_ready():
                stock_performance[stock] = self.data[symbol].get_performance()

        # sort and divide
        if len(stock_performance) >= self.top_count:
            sorted_stocks: List[Fundamental] = sorted(stock_performance, key=stock_performance.get, reverse=True)
            for stock in sorted_stocks:
                if len(self.long) >= self.top_count:
                    break
                if self.sector_manager[stock.asset_classification.morningstar_sector_code] == 0:
                    continue
                self.long.append(stock.symbol)
                self.sector_manager[stock.asset_classification.morningstar_sector_code] -= 1

        if self.time.year in self.traded_stocks_by_year:
            for symbol in self.long:
                self.traded_stocks_by_year[self.time.year].add(symbol.value)

        return self.long

    def on_data(self, slice: Slice) -> None:
        # order execution
        if not self.selection_flag:
            return
        self.selection_flag = False

        if self.inversed_volatily_weighting_flag:
            sum_ivol: float = sum([self.data[symbol].get_inverse_vol() for symbol in self.long])

        # trade execution
        targets: List[PortfolioTarget] = []
        for symbol in self.long:
            if slice.contains_key(symbol) and slice[symbol]:
                if slice[symbol].value != 0:
                    if self.inversed_volatily_weighting_flag:
                        if sum_ivol != 0:
                            targets.append(PortfolioTarget(symbol, self.data[symbol].get_inverse_vol() / sum_ivol))
                    else:
                        targets.append(PortfolioTarget(symbol, 1 / len(self.long)))
        
        if self.calculate_alpha:
            if len(self.long) != 0:
                targets.append(PortfolioTarget(self.market, -1.))
            else:
                targets.append(PortfolioTarget(self.market, 0.))
        
        self.SetHoldings(targets, True)
        self.long.clear()

        for sector, _ in self.sector_manager.items():
            self.sector_manager[sector] = self.top_count * self.sector_max_percentage

    def selection(self) -> None:
        if self.rebalance_period == RebalancePeriod.MONTHLY:
                self.selection_flag = True
        else:
            if self.time.isocalendar().week == self.current_week:
                return
            self.current_week = self.time.isocalendar().week
            if self.rebalance_period == RebalancePeriod.WEEKLY:
                self.selection_flag = True
            if self.rebalance_period == RebalancePeriod.BIWEEKLY:
                self.counter += 1
                if self.counter == 2:
                    self.selection_flag = True
                    self.counter = 0
                    
        if not self.rolling_perf_flag:
            # reset price data
            if self.time.year != self.curr_year:
                self.curr_year = self.time.year
                for symbol, symbol_data in self.data.items():
                    symbol_data.reset()

    def on_end_of_algorithm(self) -> None:
        for year, stocks in self.traded_stocks_by_year.items():
            self.log(f'stocks traded in {year}: {stocks}')