Overall Statistics
Total Trades
0
Average Win
0%
Average Loss
0%
Compounding Annual Return
0%
Drawdown
0%
Expectancy
0
Net Profit
0%
Sharpe Ratio
0
Probabilistic Sharpe Ratio
0%
Loss Rate
0%
Win Rate
0%
Profit-Loss Ratio
0
Alpha
0
Beta
0
Annual Standard Deviation
0
Annual Variance
0
Information Ratio
-0.407
Tracking Error
0.162
Treynor Ratio
0
Total Fees
$0.00
Estimated Strategy Capacity
$0
Lowest Capacity Asset
Portfolio Turnover
0%
#region imports
from AlgorithmImports import *
#endregion
# https://quantpedia.com/Screener/Details/14

import numpy as np
import pandas as pd
from scipy.stats import linregress
from collections import deque
from datetime import timedelta
from datetime import datetime
import math

# TODO: add volatility based position sizes - risk parity - DONE
# TODO: add rebalancing based on risk parity every month
# TODO: remove securities if they fall out of the top momentum ranking AND fall under the sma filter
# TODO: fix margin issue / errors - DONE (mostly)
# TODO: try filtering on momentum, close > sma and rsi(3)  
# TODO: add in the money puts when index < 200 sma based on momentum rank 

class MomentumRanker(PythonIndicator):
    
    def momentum(self, closes):
        returns = np.log(closes)
        x = np.arange(len(returns))
        slope, _, rvalue, _, _ = linregress(x, returns)
        return ((1 + slope) ** 252) * (rvalue ** 2)  # annualize slope and multiply by R^2

    def __init__(self, period):
        self.Time = datetime.min
        self.period = period
        self.closes = deque(maxlen=period)  # queue for storing closes
        self.Value = 0
    
    def __repr__(self):
        return f"Momentum Ranker -> IsReady: {self.IsReady}. Time: {self.Time}. Value: {self.Value}." 

    def IsReady(self):
        return (self.Value > 0) # return(len(self.closes)==self.closes.maxlen)
    
    # TODO: change this to IndicatorDataPoint
    def Update(self, bar:TradeBar):
        self.closes.append(bar.Close)
        self.Time = bar.Time
    
        self.Value = 0

        if len(self.closes) == self.closes.maxlen:
            self.Value = self.momentum(np.array([*self.closes])) # convert deque to list then to np.array
        
        return (self.Value > 0)

class SymbolData:
    def __init__(self, algorithm, symbol):
        self.algorithm = algorithm
        self.symbol = symbol

        self.filter_period = 100
        self.volatility_period = 20
        self.momentum_period = 90 

        self.momentum = MomentumRanker(self.momentum_period)
        self.atr = AverageTrueRange(symbol, self.volatility_period)
        self.filter = SimpleMovingAverage(symbol, self.filter_period)

        self.consolidator = TradeBarConsolidator(1)
        algorithm.SubscriptionManager.AddConsolidator(symbol, self.consolidator)
        algorithm.RegisterIndicator(self.symbol, self.momentum)
        algorithm.RegisterIndicator(self.symbol, self.filter, self.consolidator) # maybe or maybe like momentum?
        algorithm.RegisterIndicator(self.symbol, self.atr, self.consolidator)
        
        algorithm.WarmUpIndicator(self.symbol, self.atr)
        algorithm.WarmUpIndicator(self.symbol, self.filter)

        # warmup momentum indicators
        trade_bars = self.algorithm.History[TradeBar](symbol, self.momentum_period, Resolution.Daily)
        for bar in trade_bars:
            self.momentum.Update(bar)
    
    def UpdateIndicators(self, data:TradeBar): 
        # update the indicators
        self.momentum.Update(data)
        self.filter.Update(data.Time, data.Close)  # SMA takes time, IndicatorDataPoint, may need to construct and IndicatorDataPoint
        self.atr.Update(data)                   

    def IsReady(self):
        return self.momentum.IsReady and self.atr.IsReady and self.filter.IsReady
    
    def dispose(self):
        self.algorithm.SubscriptionManager.RemoveConsolidator(self.symbol, self.consolidator)
   

class MomentumEffectAlgorithm(QCAlgorithm):

    def Initialize(self):
        self.SetStartDate(1998, 1, 1)  # Set Start Date
        self.SetEndDate(2023, 3, 1)    # Set End Date       
        self.SetCash(100000)           # Set Strategy Cash

        self.UniverseSettings.Resolution = Resolution.Daily
        self.UniverseSettings.DataNormalizationMode = DataNormalizationMode.Adjusted
        self.UniverseSettings.MinimumTimeInUniverse = timedelta(days=28)

        self.symbol_dictionary = {} # dictionary for holding SymbolData key by symbol
        self.addedSymbols = []
        self.removedSymbols = []

        # adjust these in line with Clenow - want approx 20 stocks in the portfolio
        self.num_coarse = 500 # Number of symbols selected at Coarse Selection
        self.num_fine = 100    # Number of symbols selected at Fine Selection
        self.num_positions = 20     # Number of symbols with open positions
        self.risk_factor = 0.001    # targeting 10 basis point move per day
        
        self.index_filter_period = 200

        # variables to control the portfolio rebalance and the stock selection reranking
        self.month = -1
        self.rebalance = False
        self.rerank = False

        # create the equities universe
        self.AddUniverse(self.CoarseSelectionFunction, self.FineSelectionFunction)

        self.index = self.AddEquity("SPY", Resolution.Daily).Symbol
        self.SetBenchmark(self.index)

        self.index_sma = SimpleMovingAverage(self.index, self.index_filter_period)
        self.RegisterIndicator(self.index, self.index_sma, Resolution.Daily)

        # set Brokerage model and Fee Structure
        self.SetBrokerageModel(BrokerageName.InteractiveBrokersBrokerage, AccountType.Margin)

        # set Free Cash to 2.5%
        self.Settings.FreePortfolioValuePercentage = 0.025
        
        """
        # can't use scheduling with coarse / fine universe selection
        # but TODO: can probably schedule the rerank and rebalance methods, to run after OnData
        self.Schedule.On(self.DateRules.MonthEnd(self.index, daysOffset = 0),
                         self.TimeRules.BeforeMarketClose("SPY", 60),
                         self.RerankMomentum)
        
        self.Schedule.On(self.DateRules.Every(DayOfWeek.Wednesday),
                         self.TimeRules.BeforeMarketClose("SPY", 120),
                         self.RebalancePortfolio)
        
        # Selection will run on every week on Monday 2 hours before market close
        self.AddUniverseSelection(ScheduledUniverseSelectionModel(
                                self.DateRules.Every(DayOfWeek.Monday),
                                self.TimeRules.AfterMarketClose("SPY", 15),
                                self.SelectSymbols))
        
        """

    # my methods

    def CalculateRiskParityPositionSize(self, symbol, atr)-> float:
        amountToRisk = self.Portfolio.TotalPortfolioValue * self.risk_factor
        quantity = self.CalculateOrderQuantity(symbol, 0.01) # get the quanity of shares for holding of 1%
        shares = amountToRisk / atr
        rounded_holding = 0.0
        if quantity != 0:
            holding_percent = (shares/quantity)/100
            rounded_holding = round(holding_percent, 3) # round down to 0.001
        return rounded_holding

    # QC methods
    def OnWarmUpFinished(self) -> None:
        self.Log("Equities Momentum Algorithm Ready")

    
    # this article explains when universe selection is done (12:00am each day) and the order of the calls
    # coarse, fine, onsecuritieschanged, then ondata
    # https://www.quantconnect.com/forum/discussion/6485/onsecuritieschanged-questions/p1

    # may eventually switch this out for small or mid cap stocks
    def CoarseSelectionFunction(self, coarse):
        '''Drop securities which have no fundamental data or have too low prices. Select those with highest by dollar volume'''
        
        if not self.Time.weekday() == 0: # monday
            return Universe.Unchanged

        self.rerank = True

        selected = sorted([x for x in coarse if x.HasFundamentalData and x.Price > 5], key=lambda x: x.DollarVolume, reverse=True)
        return [x.Symbol for x in selected[:self.num_coarse]]

    # may not really want to do anything here - might be that coarse filtering is enough
    def FineSelectionFunction(self, fine):
        '''Select security with highest market cap, and common stock'''
        selected = sorted(fine, key=lambda f: f.MarketCap and 
                                              f.SecurityReference.SecurityType == 'ST00000001', # stock is common stock, ST00000002 is preferred stock, 
                                              reverse=True)

        return [x.Symbol for x in selected[:self.num_fine]]
     

    def OnData(self, data):
        
        liquidated = []
        purchased = []

        # update the indidcators with new bar data
        for symbol, symbolData in self.symbol_dictionary.items():
            if data[symbol] is not None:
                symbolData.UpdateIndicators(data[symbol])

        if data.Time == datetime(1998,2,23): 
            self.Log("Time based breakpoint opportunity")

        # rerank weekly, rebalance monthly
        if not self.rerank:
            return

        if (self.month != self.Time.month):
            self.rebalance = True
            self.month = self.Time.month

        # rerank securities based on momentum and close above sma filter
        mom_list = [sym for sym, symb_data in self.symbol_dictionary[sym] if symb_data.momentum.IsReady]
        sorted_mom = sorted(mom_list, key=lambda x: self.symbol_dictionary[x].SymbolData.momentum.Current.Value, reverse=True)

        # filter selected to chose only those with a price above their sma 100 
        sorted_filter = sorted([sym for sym, symb_data in self.symbol_dictionary[sym] if symb_data.filter.IsReady],
            key=lambda x: data[x].Close > self.symbol_dictionary[x].SymbolData.filter.Current.Value)

        combined = list((set(sorted_mom)) & set(sorted_filter))
        sorted_combined = sorted(combined,key=lambda x: self.symbol_dictionary[x].SymbolData.momentum.Current.Value, reverse=True)
        selected = sorted_combined[:self.num_positions] 

        # Sell
        # Liquidate securities that are in our portfolio, but no longer in the top momentum rankings, and have closed below the filter
        for security in self.Portfolio:
            symbol = security.Symbol
            if symbol not in selected:
                if data[symbol].Close < self.symbol_dictionary[symbol].filter.Current.Value:
                    self.Liquidate(symbol, f'{symbol} {data[symbol].Close} < sma(100): {self.filter[symbol].Current.Value}')
                    liquidated.append(symbol.ID.ToString().split(' ')[0])
                      
        # log which ones were removed
        self.Log(f'{liquidated} removed by sma filter')

        # Buy
        # if we pass our index filter, buy selected securities (check not already in holdings first though)
        if data[self.index].Close > self.index_sma.Current.Value:
            buffer = (self.Portfolio.TotalPortfolioValue / 100) # 1% buffer
            for symbol in selected:
                if (not symbol in self.Portfolio):
                    remaining_margin = self.Portfolio.MarginRemaining
                    cash = self.Portfolio.Cash # might use cash instead of margin
                    if remaining_margin > buffer:
                        holding = self.CalculateRiskParityPositionSize(symbol, self.volatility[symbol].Current.Value)
                        self.SetHoldings(symbol, holding)
                        purchased.append(f"{symbol.ID.ToString().split(' ')[0]} {holding}")

        # log which ones were purchased
        self.Log(f'{purchased} added based on momentum rankings')

        portfolio_update = f'Value: {self.Portfolio.TotalPortfolioValue}, Cash: {self.Portfolio.Cash}, Margin Used: {self.Portfolio.TotalMarginUsed}, Margin Remaining {self.Portfolio.MarginRemaining}'
        self.Log(f'Portfolio: {portfolio_update}')

        if not self.rebalance:
            return

        rebalanced = []

        for symbol in self.Portfolio:
            holding = self.CalculateRiskParityPositionSize(symbol, self.symbol_dictionary[symbol].atr.Current.Value)
            self.SetHoldings(symbol, holding)
            rebalanced.append(f"{symbol.ID.ToString().split(' ')[0]} {holding},")

        self.Log(f'Portfolio Weights: {rebalanced}')

        self.rebalance = False
        self.rerank = False

 
    def OnSecuritiesChanged(self, changes):
        # Clean up securities list and indicator data for removed securities
        for security in changes.RemovedSecurities:
            symbol = security.Symbol
            if symbol in self.symbol_dictionary:
                self.symbol_dictionary.pop(symbol) 
                self.removedSymbols.append(symbol.ID.ToString())

        # Create indicators and warm them up for securities newly added to the universe
        for security in changes.AddedSecurities:
            symbol = security.Symbol
            if symbol not in self.symbol_dictionary: 
                self.symbol_dictionary[symbol] = SymbolData(self, symbol)
                self.addedSymbols.append(symbol.ID.ToString())

        self.Log(f'Added: {self.addedSymbols}')
        self.Log(f'Removed: {self.removedSymbols}')