Overall Statistics
Total Trades
4059
Average Win
0.91%
Average Loss
-0.92%
Compounding Annual Return
0.927%
Drawdown
69.800%
Expectancy
0.024
Net Profit
21.405%
Sharpe Ratio
0.14
Probabilistic Sharpe Ratio
0.001%
Loss Rate
49%
Win Rate
51%
Profit-Loss Ratio
1.00
Alpha
0.027
Beta
0.012
Annual Standard Deviation
0.2
Annual Variance
0.04
Information Ratio
-0.157
Tracking Error
0.266
Treynor Ratio
2.43
Total Fees
$660.54
# https://quantpedia.com/strategies/momentum-and-reversal-combined-with-volatility-effect-in-stocks/
#
# The investment universe consists of NYSE, AMEX, and NASDAQ stocks with prices higher than $5 per share. At the beginning of each month, 
# the sample is divided into equal halves, at the size median, and only larger stocks are used. Then each month, realized returns and realized 
# (annualized) volatilities are calculated for each stock for the past six months. One week (seven calendar days) prior to the beginning of 
# each month is skipped to avoid biases due to microstructures. Stocks are then sorted into quintiles based on their realized past returns 
# and past volatility. The investor goes long on stocks from the highest performing quintile from the highest volatility group and short on 
# stocks from the lowest-performing quintile from the highest volatility group. Stocks are equally weighted and held for six months 
# (therefore, 1/6 of the portfolio is rebalanced every month).
#
# QC implementation changes:
#   - Universe consists of top 3000 US stock by market cap from NYSE, AMEX and NASDAQ.
    
from numpy import sqrt
from collections import deque
import numpy as np

class MomentumReversalCombinedWithVolatilityEffectinStocks(QCAlgorithm):

    def Initialize(self):
        self.SetStartDate(2000, 1, 1)
        self.SetCash(100000) 

        self.symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
        
        # EW Tranching.
        self.holding_period = 6
        self.managed_queue = deque(maxlen = self.holding_period + 1)

        # Daily price data.
        self.data = {}
        self.period = 6 * 21
        
        self.coarse_count = 500
        self.selection_flag = True
        self.UniverseSettings.Resolution = Resolution.Daily
        self.AddUniverse(self.CoarseSelectionFunction, self.FineSelectionFunction)
        self.Schedule.On(self.DateRules.MonthEnd(self.symbol), self.TimeRules.AfterMarketOpen(self.symbol), self.Selection)

    def OnSecuritiesChanged(self, changes):
        for security in changes.AddedSecurities:
            security.SetFeeModel(CustomFeeModel(self))
            security.SetLeverage(5)
            
    def CoarseSelectionFunction(self, coarse):
        # Update the rolling window every day.
        for stock in coarse:
            symbol = stock.Symbol

            # Store monthly price.
            if symbol in self.data:
                self.data[symbol].update(stock.AdjustedPrice)

        if not self.selection_flag:
            return Universe.Unchanged
        
        # selected = [x for x in coarse if x.HasFundamentalData and x.Market == 'usa' and x.Price > 5]
        
        selected = sorted([x for x in coarse if x.HasFundamentalData and x.Market == 'usa' and x.Price > 5],    \
                key = lambda x: x.DollarVolume, reverse = True)[:self.coarse_count]
        
        # Warmup price rolling windows.
        for stock in selected:
            symbol = stock.Symbol
            if symbol in self.data:
                continue
            
            self.data[symbol] = SymbolData(symbol, self.period)
            history = self.History(symbol, self.period, Resolution.Daily)
            if history.empty:
                self.Log(f"Not enough data for {symbol} yet.")
                continue
            closes = history.loc[symbol].close
            for time, close in closes.iteritems():
                self.data[symbol].update(close)
                
        return [x.Symbol for x in selected if self.data[x.Symbol].is_ready()]

    def FineSelectionFunction(self, fine):
        fine = [x for x in fine if x.MarketCap != 0 and \
                    ((x.SecurityReference.ExchangeId == "NYS") or (x.SecurityReference.ExchangeId == "NAS") or (x.SecurityReference.ExchangeId == "ASE"))]
                    
        sorted_by_market_cap = sorted(fine, key = lambda x: x.MarketCap, reverse=True)
        half = int(len(sorted_by_market_cap) / 2)
        top_by_market_cap = [x.Symbol for x in sorted_by_market_cap][:half]
        
        # Performance and volatility tuple.
        perf_volatility = {}
        for symbol in top_by_market_cap:
            performance = self.data[symbol].performance()
            annualized_volatility = self.data[symbol].volatility()
            perf_volatility[symbol] = (performance, annualized_volatility)
        
        sorted_by_perf = sorted(perf_volatility.items(), key = lambda x: x[1][0], reverse = True)
        quintile = int(len(sorted_by_perf) / 5)
        top_by_perf = [x[0] for x in sorted_by_perf[:quintile]]
        low_by_perf = [x[0] for x in sorted_by_perf[-quintile:]]
        
        sorted_by_vol = sorted(perf_volatility.items(), key = lambda x: x[1][1], reverse = True)
        quintile = int(len(sorted_by_vol) / 5)
        top_by_vol = [x[0] for x in sorted_by_vol[:quintile]]
        low_by_vol = [x[0] for x in sorted_by_vol[-quintile:]]
        
        long = [x for x in top_by_perf if x in top_by_vol if not self.IsInvested(x)]
        short = [x for x in low_by_perf if x in top_by_vol if not self.IsInvested(x)]

        self.managed_queue.append(RebalanceQueueItem(long, short))
        
        return long + short
        
    def OnData(self, data):
        if not self.selection_flag:
            return
        self.selection_flag = False
       
        # Trade execution.
        if len(self.managed_queue) == 0: return
    
        # Liquidate first items if queue is full.
        if len(self.managed_queue) == self.managed_queue.maxlen:
            item_to_liquidate = self.managed_queue.popleft()
            for symbol in item_to_liquidate.long_symbols + item_to_liquidate.short_symbols:
                self.Liquidate(symbol)
            
        curr_stock_set = self.managed_queue[-1]
        if curr_stock_set.count == 0: return
    
        weight = 1 / self.holding_period
        
        # Open new trades.
        for symbol in curr_stock_set.long_symbols:
            self.SetHoldings(symbol, weight / len(curr_stock_set.long_symbols))
            
        for symbol in curr_stock_set.short_symbols:
            self.SetHoldings(symbol, -weight / len(curr_stock_set.short_symbols))
    
    def Selection(self):
        self.selection_flag = True
        
    def IsInvested(self, symbol):
        return self.Securities.ContainsKey(symbol) and self.Portfolio[symbol].Invested

class RebalanceQueueItem():
    def __init__(self, long_symbols, short_symbols):
        self.long_symbols = long_symbols
        self.short_symbols = short_symbols
        self.count = len(long_symbols + short_symbols)

class SymbolData():
    def __init__(self, symbol, period):
        self.Symbol = symbol
        self.Price = RollingWindow[float](period)
    
    def update(self, value):
        self.Price.Add(value)
    
    def is_ready(self):
        return self.Price.IsReady
    
    def update(self, close):
        self.Price.Add(close)
        
    def volatility(self):
        closes = np.array([x for x in self.Price][5:]) # Skip last week.
        daily_returns = closes[:-1] / closes[1:] - 1
        return np.std(daily_returns) * sqrt(252 / (len(closes)))
        
    def performance(self):
        closes = [x for x in self.Price][5:] # Skip last week.
        return (closes[0] / closes[-1] - 1)

# Custom fee model.
class CustomFeeModel(FeeModel):
    def GetOrderFee(self, parameters):
        fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
        return OrderFee(CashAmount(fee, "USD"))