Overall Statistics
Total Trades
2019
Average Win
0.19%
Average Loss
-0.23%
Compounding Annual Return
9.717%
Drawdown
26.700%
Expectancy
0.046
Net Profit
29.089%
Sharpe Ratio
0.584
Probabilistic Sharpe Ratio
20.967%
Loss Rate
43%
Win Rate
57%
Profit-Loss Ratio
0.85
Alpha
0.062
Beta
0.273
Annual Standard Deviation
0.159
Annual Variance
0.025
Information Ratio
-0.097
Tracking Error
0.213
Treynor Ratio
0.339
Total Fees
$4562.54
### PRODUCT INFORMATION --------------------------------------------------------------------------------
# Copyright Emilio Freire Bauzano
# Use entirely at your own risk.
# This algorithm contains open source code from other sources and no claim is being made to such code.
# Do not remove this copyright notice.
### ----------------------------------------------------------------------------------------------------

from FactorModelUniverseSelection import FactorModelUniverseSelectionModel
from LongShortAlphaCreation import LongShortAlphaCreationModel
from CustomEqualWeightingPortfolioConstruction import CustomEqualWeightingPortfolioConstructionModel

class LongShortEquityFrameworkAlgorithm(QCAlgorithmFramework):
    
    '''
    Trading Logic:
        Long-Short Equity Strategy using factor modelling
    Modules:
        Universe:
            - Final selection based on factor modelling:
                Combination of technical and fundamental factors
            - Long the Top N stocks
            - Short the Bottom N stocks
        Alpha: Creation of Up/Down Insights at the Market Open:
            - Up Insights (to go Long)
            - Down Insights (to go Short)
        Portfolio:
            Equal-Weighting Portfolio with monthly rebalancing
        Execution:
            Immediate Execution with Market Orders
        Risk:
            Null
    '''

    def Initialize(self):
        
        ### user-defined inputs ---------------------------------------------------------------------------

        self.SetStartDate(2018, 1, 1)   # set start date
        self.SetEndDate(2020, 10, 1)    # set end date
        self.SetCash(1000000)           # set strategy cash
        
        # select benchmark ticker
        benchmark = 'SPY'
        
        # date rule for rebalancing our portfolio by updating long-short positions based on factor values
        rebalancingFunc = Expiry.EndOfMonth
        
        # number of stocks to keep for factor modelling calculations
        nStocks = 100
        
        # number of positions to hold on each side (long/short)
        positionsOnEachSide = 20
        
        # lookback for historical data to calculate factors
        lookback = 252
        
        # select the leverage factor
        leverageFactor = 1
        
        ### --------------------------------------------------------------------------------------------------
        
        # calculate initialAllocationPerSecurity and maxNumberOfPositions
        initialAllocationPerSecurity = (1 / positionsOnEachSide) * leverageFactor
        maxNumberOfPositions = positionsOnEachSide * 2
        
        # set requested data resolution
        self.UniverseSettings.Resolution = Resolution.Hour
        # add leverage to new securities (this does not add leverage to current holdings in the account)
        leverageNeeded = max(1, maxNumberOfPositions * initialAllocationPerSecurity * leverageFactor)
        self.UniverseSettings.Leverage = leverageNeeded + 1
        
        # let's plot the series of daily total portfolio exposure %
        portfolioExposurePlot = Chart('Chart Total Portfolio Exposure %')
        portfolioExposurePlot.AddSeries(Series('Daily Portfolio Exposure %', SeriesType.Line, ''))
        self.AddChart(portfolioExposurePlot)
        
        # let's plot the series of daily number of open longs and shorts
        nLongShortPlot = Chart('Chart Number Of Longs/Shorts')
        nLongShortPlot.AddSeries(Series('Daily N Longs', SeriesType.Line, ''))
        nLongShortPlot.AddSeries(Series('Daily N Shorts', SeriesType.Line, ''))
        self.AddChart(nLongShortPlot)
        
        # let's plot the series of drawdown % from the most recent high
        drawdownPlot = Chart('Chart Drawdown %')
        drawdownPlot.AddSeries(Series('Drawdown %', SeriesType.Line, '%'))
        self.AddChart(drawdownPlot)
        
        # add benchmark
        self.SetBenchmark(benchmark)
        
        # select modules                               
        self.SetUniverseSelection(FactorModelUniverseSelectionModel(benchmark = benchmark,
                                                                    nStocks = nStocks,
                                                                    lookback = lookback,
                                                                    maxNumberOfPositions = maxNumberOfPositions,
                                                                    rebalancingFunc = rebalancingFunc))
        
        self.SetAlpha(LongShortAlphaCreationModel(maxNumberOfPositions = maxNumberOfPositions, lookback = lookback))
        
        self.SetPortfolioConstruction(CustomEqualWeightingPortfolioConstructionModel(initialAllocationPerSecurity = initialAllocationPerSecurity,
                                                                                    rebalancingFunc = rebalancingFunc))
                                                                                    
        self.SetExecution(ImmediateExecutionModel())
        
        self.SetRiskManagement(NullRiskManagementModel())
from clr import AddReference
AddReference("QuantConnect.Common")
AddReference("QuantConnect.Algorithm")
AddReference("QuantConnect.Algorithm.Framework")

from QuantConnect import *
from QuantConnect.Algorithm import *
from QuantConnect.Algorithm.Framework import *
from QuantConnect.Algorithm.Framework.Alphas import AlphaModel, Insight, InsightType, InsightDirection

from HelperFunctions import GetFundamentalDataDict, MakeCalculations, GetLongShortLists
from datetime import timedelta, datetime
import pandas as pd
import numpy as np

class LongShortAlphaCreationModel(AlphaModel):

    def __init__(self, maxNumberOfPositions = 10, lookback = 252):
        
        self.maxNumberOfPositions = maxNumberOfPositions
        self.lookback = lookback
        
        self.securities = []
        self.day = 0

    def Update(self, algorithm, data):
        
        insights = [] # list to store the new insights to be created
        
        if algorithm.Time.day != self.day and algorithm.Time.hour > 9:
            for symbol, direction in self.insightsDict.items():
                if data.ContainsKey(symbol) and symbol in algorithm.ActiveSecurities.Keys and algorithm.ActiveSecurities[symbol].Price > 0:
                    insights.append(Insight.Price(symbol, Expiry.EndOfDay, direction))
                    
            self.day = algorithm.Time.day

        return insights
            
    def OnSecuritiesChanged(self, algorithm, changes):
        
        '''
        Description:
            Event fired each time the we add/remove securities from the data feed
        Args:
            algorithm: The algorithm instance that experienced the change in securities
            changes: The security additions and removals from the algorithm
        '''
            
        # check current securities in our self.securities list
        securitiesList = [x.Symbol.Value for x in self.securities]
        algorithm.Log('(Alpha module) securities in self.securities before OnSecuritiesChanged: ' + str(securitiesList))
            
        # add new securities
        addedSecurities = [x for x in changes.AddedSecurities if x not in self.securities]
        for added in addedSecurities:
            self.securities.append(added)
            
        newSecuritiesList = [x.Symbol.Value for x in addedSecurities]
        algorithm.Log('(Alpha module) new securities added to self.securities:'+ str(newSecuritiesList))

        # remove securities
        removedSecurities = [x for x in changes.RemovedSecurities if x in self.securities]
        for removed in removedSecurities:
            self.securities.remove(removed)
                
        removedList = [x.Symbol.Value for x in removedSecurities]
        algorithm.Log('(Alpha module) securities removed from self.securities: ' + str(removedList))
        
        # print the final securities in self.securities for today
        securitiesList = [x.Symbol.Value for x in self.securities]
        algorithm.Log('(Alpha module) final securities in self.securities after OnSecuritiesChanged: ' + str(securitiesList))
        
        # generate dictionary with factors -------------------------------------------------------
        fundamentalDataBySymbolDict = GetFundamentalDataDict(algorithm, self.securities, module = 'alpha')
                    
        # make calculations to create long/short lists -------------------------------------------
        currentSymbols = list(fundamentalDataBySymbolDict.keys())
        calculations = MakeCalculations(algorithm, currentSymbols, self.lookback, Resolution.Daily, fundamentalDataBySymbolDict)
        
        # get long/short lists
        longs, shorts = GetLongShortLists(self, algorithm, calculations)
        finalSymbols = longs + shorts
        
        # update the insightsDict dictionary with long/short signals
        self.insightsDict = {}
        for symbol in finalSymbols:
            if symbol in longs:
                direction = 1
            else:
                direction = -1
                
            self.insightsDict[symbol] = direction
from clr import AddReference
AddReference("QuantConnect.Common")
AddReference("QuantConnect.Algorithm.Framework")

from QuantConnect import Resolution, Extensions
from QuantConnect.Algorithm.Framework.Alphas import *
from QuantConnect.Algorithm.Framework.Portfolio import *
from itertools import groupby
from datetime import datetime, timedelta

class CustomEqualWeightingPortfolioConstructionModel(PortfolioConstructionModel):
    
    '''
    Description:
        Provide a custom implementation of IPortfolioConstructionModel that gives equal weighting to all active securities
    Details:
        - The target percent holdings of each security is 1/N where N is the number of securities with active Up/Down insights
        - For InsightDirection.Up, long targets are returned
        - For InsightDirection.Down, short targets are returned
        - For InsightDirection.Flat, closing position targets are returned
    '''

    def __init__(self, initialAllocationPerSecurity = 0.1, rebalancingFunc = Expiry.EndOfMonth):
        
        '''
        Description:
            Initialize a new instance of CustomEqualWeightingPortfolioConstructionModel
        Args:
            initialAllocationPerSecurity: Portfolio exposure per security (as a % of total equity)
        '''
        
        # portfolio exposure per security (as a % of total equity)
        self.initialAllocationPerSecurity = initialAllocationPerSecurity
        self.rebalancingFunc = rebalancingFunc
        
        self.insightCollection = InsightCollection()
        self.removedSymbols = []
        
        self.nextRebalance = None

    def CreateTargets(self, algorithm, insights):

        '''
        Description:
            Create portfolio targets from the specified insights
        Args:
            algorithm: The algorithm instance
            insights: The insights to create portfolio targets from
        Returns:
            An enumerable of portfolio targets to be sent to the execution model
        '''

        targets = []
            
        if len(insights) == 0:
            return targets
        
        # apply rebalancing logic
        if self.nextRebalance is not None and algorithm.Time < self.nextRebalance and len(self.removedSymbols) == 0:
            return targets
        self.nextRebalance = self.rebalancingFunc(algorithm.Time)
        
        # here we get the new insights and add them to our insight collection
        for insight in insights:
            self.insightCollection.Add(insight)
            
        # create flatten target for each security that was removed from the universe
        if len(self.removedSymbols) > 0:
            universeDeselectionTargets = [ PortfolioTarget(symbol, 0) for symbol in self.removedSymbols ]
            targets.extend(universeDeselectionTargets)
            algorithm.Log('(Portfolio module) liquidating: ' + str([x.Value for x in self.removedSymbols]))
            self.removedSymbols = []

        # get insight that have not expired of each symbol that is still in the universe
        activeInsights = self.insightCollection.GetActiveInsights(algorithm.UtcTime)

        # get the last generated active insight for each symbol
        lastActiveInsights = []
        for symbol, g in groupby(activeInsights, lambda x: x.Symbol):
            lastActiveInsights.append(sorted(g, key = lambda x: x.GeneratedTimeUtc)[-1])
        
        # determine target percent for the given insights
        for insight in lastActiveInsights:
            allocationPercent = self.initialAllocationPerSecurity * insight.Direction
            target = PortfolioTarget.Percent(algorithm, insight.Symbol, allocationPercent)
            targets.append(target)
            
        return targets
        
    def OnSecuritiesChanged(self, algorithm, changes):
        
        '''
        Description:
            Event fired each time the we add/remove securities from the data feed
        Args:
            algorithm: The algorithm instance that experienced the change in securities
            changes: The security additions and removals from the algorithm
        '''
        
        newRemovedSymbols = [x.Symbol for x in changes.RemovedSecurities if x.Symbol not in self.removedSymbols]
        
        # get removed symbol and invalidate them in the insight collection
        self.removedSymbols.extend(newRemovedSymbols)
        self.insightCollection.Clear(self.removedSymbols)
            
        removedList = [x.Value for x in self.removedSymbols]
        algorithm.Log('(Portfolio module) securities removed from Universe: ' + str(removedList))
import pandas as pd
from scipy.stats import zscore
from classSymbolData import SymbolData

def MakeCalculations(algorithm, symbols, lookback, resolution, fundamentalDataBySymbolDict):
    
    '''
    Description:
        Make required calculations using historical data for each symbol
    Args:
        symbols: The symbols to make calculations for
        lookback: Lookback period for historical data
        resolution: Resolution for historical data
        fundamentalDataBySymbolDict: Dictionary of symbols containing factors and the direction of the factor (for sorting)
    Return:
        calculations: Dictionary containing the calculations per symbol
    '''
    
    # store calculations
    calculations = {}

    if len(symbols) > 0:
        # get historical prices for new symbols
        history = GetHistory(algorithm, symbols,
                            lookbackPeriod = lookback,
                            resolution = resolution)
            
        for symbol in symbols:
            # if symbol has no historical data continue the loop
            if (str(symbol) not in history.index
            or len(history.loc[str(symbol)]['close']) < lookback
            or history.loc[str(symbol)].get('close') is None
            or history.loc[str(symbol)].get('close').isna().any()):
                algorithm.Log('no history found for: ' + str(symbol.Value))
                continue

            else:
                # add symbol to calculations
                calculations[symbol] = SymbolData(symbol)
                
                try:
                    calculations[symbol].CalculateFactors(history, fundamentalDataBySymbolDict)
                except Exception as e:
                    algorithm.Log('removing from calculations due to ' + str(e))
                    calculations.pop(symbol)
                    continue
                
    return calculations
    
def GetFundamentalDataDict(algorithm, securitiesData, module = 'universe'):
    
    ''' Create a dictionary of symbols and fundamental factors ready for sorting '''

    fundamentalDataBySymbolDict = {}
    
    # loop through data and get fundamental data
    for x in securitiesData:
        if module == 'alpha':
            if not x.Symbol in algorithm.ActiveSecurities.Keys:
                continue
            fundamental = algorithm.ActiveSecurities[x.Symbol].Fundamentals
        elif module == 'universe':
            fundamental = x
        else:
            raise ValueError('module argument must be either universe or alpha')
            
        # dictionary of symbols containing factors and the direction of the factor (1 for sorting descending and -1 for sorting ascending)
        fundamentalDataBySymbolDict[x.Symbol] = {
                                                    #fundamental.ValuationRatios.BookValuePerShare: 1,
                                                    #fundamental.FinancialStatements.BalanceSheet.TotalEquity.Value: -1,
                                                    #fundamental.OperationRatios.OperationMargin.Value: 1,
                                                    #fundamental.OperationRatios.ROE.Value: 1,
                                                    #fundamental.OperationRatios.TotalAssetsGrowth.Value: 1,
                                                    #fundamental.ValuationRatios.PERatio: 1
                                                }
                                                    
        # check validity of data
        if None in list(fundamentalDataBySymbolDict[x.Symbol].keys()):
            fundamentalDataBySymbolDict.pop(x.Symbol)
                                                    
    return fundamentalDataBySymbolDict
    
def GetLongShortLists(self, algorithm, calculations):
    
    ''' Create lists of long/short stocks '''
            
    # get factors
    factorsDict = { symbol: symbolData.factorsList for symbol, symbolData in calculations.items() if symbolData.factorsList is not None }
    factorsDf = pd.DataFrame.from_dict(factorsDict, orient = 'index')
    
    # normalize factor
    normFactorsDf = factorsDf.apply(zscore)
    normFactorsDf.columns = ['Factor_' + str(x + 1) for x in normFactorsDf.columns]
    
    # combine factors using equal weighting
    #normFactorsDf['combinedFactor'] = normFactorsDf.sum(axis = 1)
    normFactorsDf['combinedFactor'] = normFactorsDf['Factor_1'] * 1 + normFactorsDf['Factor_2'] * 1
        
    # sort descending
    sortedNormFactorsDf = normFactorsDf.sort_values(by = 'combinedFactor', ascending = False) # descending
    
    # create long/short lists
    positionsEachSide = int(self.maxNumberOfPositions / 2)
    longs = list(sortedNormFactorsDf[:positionsEachSide].index)
    shorts = list(sortedNormFactorsDf[-positionsEachSide:].index)
    shorts = [x for x in shorts if x not in longs]
    
    return longs, shorts

def GetHistory(algorithm, symbols, lookbackPeriod, resolution):
    
    ''' Pull historical data in batches '''
    
    total = len(symbols)
    batchsize = 50
    
    if total <= batchsize:
        history = algorithm.History(symbols, lookbackPeriod, resolution)
    else:
        history = algorithm.History(symbols[0:batchsize], lookbackPeriod, resolution)
        for i in range(batchsize, total + 1, batchsize):
            batch = symbols[i:(i + batchsize)]
            historyTemp = algorithm.History(batch, lookbackPeriod, resolution)
            history = pd.concat([history, historyTemp])
            
    return history
    
def UpdateBenchmarkValue(self, algorithm):
        
    ''' Simulate buy and hold the Benchmark '''
    
    if self.initBenchmarkPrice == 0:
        self.initBenchmarkCash = algorithm.Portfolio.Cash
        self.initBenchmarkPrice = algorithm.Benchmark.Evaluate(algorithm.Time)
        self.benchmarkValue = self.initBenchmarkCash
    else:
        currentBenchmarkPrice = algorithm.Benchmark.Evaluate(algorithm.Time)
        self.benchmarkValue = (currentBenchmarkPrice / self.initBenchmarkPrice) * self.initBenchmarkCash
        
def UpdatePlots(self, algorithm):
    
    ''' Update Portfolio Exposure and Drawdown plots '''
    
    # simulate buy and hold the benchmark and plot its daily value --------------
    UpdateBenchmarkValue(self, algorithm)
    algorithm.Plot('Strategy Equity', self.benchmark, self.benchmarkValue)

    # get current portfolio value
    currentTotalPortfolioValue = algorithm.Portfolio.TotalPortfolioValue
    
    # plot the daily total portfolio exposure % --------------------------------
    longHoldings = sum([x.HoldingsValue for x in algorithm.Portfolio.Values if x.IsLong])
    shortHoldings = sum([x.HoldingsValue for x in algorithm.Portfolio.Values if x.IsShort])
    totalHoldings = longHoldings + shortHoldings
    totalPortfolioExposure = (totalHoldings / currentTotalPortfolioValue) * 100
    algorithm.Plot('Chart Total Portfolio Exposure %', 'Daily Portfolio Exposure %', totalPortfolioExposure)
    
    # plot the daily number of longs and shorts --------------------------------
    nLongs = sum(x.IsLong for x in algorithm.Portfolio.Values)
    nShorts = sum(x.IsShort for x in algorithm.Portfolio.Values)
    algorithm.Plot('Chart Number Of Longs/Shorts', 'Daily N Longs', nLongs)
    algorithm.Plot('Chart Number Of Longs/Shorts', 'Daily N Shorts', nShorts)
    
    # plot the drawdown % from the most recent high ---------------------------
    if not self.portfolioValueHighInitialized:
        self.portfolioHigh = currentTotalPortfolioValue # set initial portfolio value
        self.portfolioValueHighInitialized = True
        
    # update trailing high value of the portfolio
    if self.portfolioValueHigh < currentTotalPortfolioValue:
        self.portfolioValueHigh = currentTotalPortfolioValue

    currentDrawdownPercent = ((float(currentTotalPortfolioValue) / float(self.portfolioValueHigh)) - 1.0) * 100
    algorithm.Plot('Chart Drawdown %', 'Drawdown %', currentDrawdownPercent)
import matplotlib.pyplot as plt
import matplotlib.ticker as mtick
import statsmodels.api as sm
import pandas as pd
import numpy as np

import seaborn as sns
sns.set_style('darkgrid')
pd.plotting.register_matplotlib_converters()

from statsmodels.regression.rolling import RollingOLS
from io import StringIO

class RiskAnalysis:
    
    def __init__(self, qb):
        
        # get Fama-French and industry factors
        industryFactorsUrl = 'https://www.dropbox.com/s/24bjtztzglo3eyf/12_Industry_Portfolios_Daily.CSV?dl=1'
        ffFiveFactorsUrl = 'https://www.dropbox.com/s/88m1nohi597et20/F-F_Research_Data_5_Factors_2x3_daily.CSV?dl=1'
        self.industryFactorsDf = self.GetExternalFactorsDf(qb, industryFactorsUrl)
        self.ffFiveFactorsDf = self.GetExternalFactorsDf(qb, ffFiveFactorsUrl)
        
    def GetExternalFactorsDf(self, qb, url):
        
        '''
        Description:
            Download a DataFrame with data from external sources
        Args:
            qb: QuantBook
            url: URL for the data source
        Returns:
            SingleIndex Dataframe
        '''
    
        strFile = qb.Download(url)
        df = pd.read_csv(StringIO(strFile), sep = ',')
        df['Date'] = pd.to_datetime(df['Date'], format = '%Y%m%d')
        df.set_index('Date', inplace = True)
        df = df.div(100)
        df.drop('RF', axis = 1, errors = 'ignore', inplace = True)

        return df
    
    def GetCombinedReturnsDf(self, returnsDf, externalFactorsDf = None):
        
        '''
        Description:
            Merge two DataFrames
        Args:
            returnsDf: SingleIndex Dataframe with returns from our strategy
            externalFactorsDf: SingleIndex Dataframe with returns from external factors
        Returns:
            SingleIndex Dataframe with returns
        '''
        
        # if no externalFactorsDf is provided, use the default Fama-French Five Factors
        if externalFactorsDf is None:
            externalFactorsDf = self.ffFiveFactorsDf
        
        # merge returnsDf with externalFactorsDf
        combinedReturnsDf = pd.merge(returnsDf, externalFactorsDf, left_index = True, right_index = True)
        
        return combinedReturnsDf
    
    def GetCumulativeReturnsDf(self, returnsDf):
        
        '''
        Description:
            Convert a DataFrame of returns into a DataFrame of cumulative returns
        Args:
            returnsDf: SingleIndex Dataframe with returns
        Returns:
            SingleIndex Dataframe with cumulative returns
        '''
        
        cumulativeReturnsDf = returnsDf.add(1).cumprod().add(-1)
        
        return cumulativeReturnsDf
        
    def RunRegression(self, returnsDf, dependentColumn = 'Strategy'):
        
        '''
        Description:
            Run Regression using the dependentColumn against the rest of the columns
        Args:
            returnsDf: SingleIndex Dataframe with returns
            dependentColumn: Name for the column to be used as dependent variable
        Returns:
            Summary of the model
        '''
        
        # create variables
        Y = returnsDf[[dependentColumn]]
        X = returnsDf[[x for x in returnsDf.columns if x != dependentColumn]]
        # adding a constant
        X = sm.add_constant(X)

        # fit regression model
        model = sm.OLS(Y, X).fit()
        
        # show summary from the model
        print(model.summary())
        
        return model
        
    def RunRollingRegression(self, returnsDf, dependentColumn = 'Strategy', lookback = 126):
        
        '''
        Description:
            Run Rolling Regression using the dependentColumn against the rest of the columns
        Args:
            returnsDf: SingleIndex Dataframe with returns
            dependentColumn: Name for the column to be used as dependent variable
            lookback: Number of observations for the lookback window
        Returns:
            Rolling Regression Model
        '''
        
        endog = returnsDf[[dependentColumn]]
        exogVariables = [x for x in returnsDf.columns if x != dependentColumn]
        exog = sm.add_constant(returnsDf[exogVariables])
        rollingModel = RollingOLS(endog, exog, window = lookback).fit()
        
        return rollingModel

    # ploting functions -----------------------------------------------------------------------------------------
        
    def PlotCumulativeReturns(self, returnsDf):

        '''
        Description:
            Plot cumulative returns
        Args:
            returnsDf: SingleIndex Dataframe with returns
        Returns:
            Plot cumulative returns
        '''
        
        # calculate cumulative returns
        cumulativeReturnsDf = self.GetCumulativeReturnsDf(returnsDf)
        # take logarithm for better visualization
        cumulativeReturnsDf = np.log(1 + cumulativeReturnsDf)
        
        # prepare plot
        fig, ax = plt.subplots(figsize = (12, 5))
        
        # plot portfolio
        colPortfolio = cumulativeReturnsDf.iloc[:, [0]].columns[0]
        ax.plot(cumulativeReturnsDf[colPortfolio], color = 'black', linewidth = 2)
            
        if len(cumulativeReturnsDf.columns) > 1:
            colFactors = cumulativeReturnsDf.iloc[:, 1:].columns
            # plot factors
            ax.plot(cumulativeReturnsDf[colFactors], alpha = 0.5)
            
        # formatting
        ax.axhline(y = 0, color = 'black', linestyle = '--', linewidth = 0.5)
        ax.set_title('Cumulative Log-Returns', fontdict = {'fontsize': 15})
        ax.yaxis.set_major_formatter(mtick.PercentFormatter(1.0))
        ax.legend(cumulativeReturnsDf.columns, loc = 'best')
        
        plt.show()
        
    def PlotRegressionModel(self, returnsDf, dependentColumn = 'Strategy'):
        
        '''
        Description:
            Run Regression and plot partial regression
        Args:
            returnsDf: SingleIndex Dataframe with returns
            dependentColumn: Name for the column to be used as dependent variable
        Returns:
            Summary of the regression model and partial regression plots
        '''
        
        # run regression
        model = self.RunRegression(returnsDf, dependentColumn)

        # plot partial regression
        exogVariables = [x for x in returnsDf.columns if x != dependentColumn]
        figsize = (10, len(exogVariables) * 2)
        fig = plt.figure(figsize = figsize)
        fig = sm.graphics.plot_partregress_grid(model, fig = fig)
        
        plt.show()
        
    def PlotRollingRegressionCoefficients(self, returnsDf, dependentColumn = 'Strategy', lookback = 126):
        
        '''
        Description:
            Run Rolling Regression and plot the time series of estimated coefficients for each predictor
        Args:
            returnsDf: SingleIndex Dataframe with returns
            dependentColumn: Name for the column to be used as dependent variable
            lookback: Number of observations for the lookback window
        Returns:
            Plot of time series of estimated coefficients for each predictor
        '''
        
        # run rolling regression
        rollingModel = self.RunRollingRegression(returnsDf, dependentColumn, lookback)
        exogVariables = [x for x in returnsDf.columns if x != dependentColumn]
        
        # plot
        figsize = (10, len(exogVariables) * 3)
        fig = rollingModel.plot_recursive_coefficient(variables = exogVariables, figsize = figsize)
        
        plt.show()
        
    def PlotBoxPlotRollingFactorExposure(self, returnsDf, dependentColumn = 'Strategy', lookback = 126):
        
        '''
        Description:
            Run Rolling Regression and make a box plot with the distributions of the estimated coefficients
        Args:
            returnsDf: SingleIndex Dataframe with returns
            dependentColumn: Name for the column to be used as dependent variable
            lookback: Number of observations for the lookback window
        Returns:
            Box plot with distributions of estimated coefficients during the rolling regression
        '''
        
        # run rolling regression
        rollingModel = self.RunRollingRegression(returnsDf, dependentColumn, lookback)
        
        fig, ax = plt.subplots(figsize = (10, 8))
        ax = sns.boxplot(data = rollingModel.params.dropna().drop('const', axis = 1), 
                        width = 0.5,
                        palette = "colorblind",
                        orient = 'h')
        ax.axvline(x = 0, color = 'black', linestyle = '--', linewidth = 0.5)
        ax.set_title('Distribution of Risk Factor Rolling Exposures', fontdict = {'fontsize': 15})
        
        plt.show()
    
    # run full risk analysis --------------------------------------------------------------------------------------
    
    def RunRiskAnalysis(self, returnsDf, externalFactorsDf = None, dependentColumn = 'Strategy', lookback = 126):
        
        # if no externalFactorsDf is provided, use the default Fama-French Five Factors
        if externalFactorsDf is None:
            externalFactorsDf = self.ffFiveFactorsDf
        
        # merge returnsDf with externalFactorsDf
        combinedReturnsDf = pd.merge(returnsDf, externalFactorsDf, left_index = True, right_index = True)
        
        # plot
        self.PlotCumulativeReturns(combinedReturnsDf)
        print('---------------------------------------------------------------------------------------------')
        print('---- Regression Analysis --------------------------------------------------------------------')
        print('---------------------------------------------------------------------------------------------')
        self.PlotRegressionModel(combinedReturnsDf, dependentColumn)
        print('---------------------------------------------------------------------------------------------')
        print('---- Rolling Regression Analysis (Rolling Coefficients) -------------------------------------')
        print('---------------------------------------------------------------------------------------------')
        self.PlotRollingRegressionCoefficients(combinedReturnsDf, dependentColumn, lookback)
        self.PlotBoxPlotRollingFactorExposure(combinedReturnsDf, dependentColumn, lookback)
from clr import AddReference
AddReference("System")
AddReference("QuantConnect.Common")
AddReference("QuantConnect.Indicators")
AddReference("QuantConnect.Algorithm.Framework")

from QuantConnect.Data.UniverseSelection import *
from Selection.FundamentalUniverseSelectionModel import FundamentalUniverseSelectionModel

from HelperFunctions import GetFundamentalDataDict, MakeCalculations, GetLongShortLists, UpdatePlots
import pandas as pd
import numpy as np

class FactorModelUniverseSelectionModel(FundamentalUniverseSelectionModel):

    def __init__(self,
                benchmark = 'SPY',
                nStocks = 500,
                lookback = 252,
                maxNumberOfPositions = 20,
                rebalancingFunc = Expiry.EndOfMonth,
                filterFineData = True,
                universeSettings = None,
                securityInitializer = None):
        
        self.benchmark = benchmark
        
        self.nStocks = nStocks
        self.lookback = lookback
        self.maxNumberOfPositions = maxNumberOfPositions

        self.rebalancingFunc = rebalancingFunc
        self.nextRebalance = None
        
        self.initBenchmarkPrice = 0
        self.portfolioValueHigh = 0 # initialize portfolioValueHigh for drawdown calculation
        self.portfolioValueHighInitialized = False # initialize portfolioValueHighInitialized for drawdown calculation
        
        super().__init__(filterFineData, universeSettings, securityInitializer)

    def SelectCoarse(self, algorithm, coarse):
        
        ''' Perform Universe selection based on price and volume '''
        
        # update plots -----------------------------------------------------------------------------------------------
        UpdatePlots(self, algorithm)
        
        # rebalancing logic -------------------------------------------------------------------------------------------
        if self.nextRebalance is not None and algorithm.Time < self.nextRebalance:
            return Universe.Unchanged
        self.nextRebalance = self.rebalancingFunc(algorithm.Time)
        
        # get new coarse candidates -----------------------------------------------------------------------------------

        # filtered by price and select the top dollar volume stocks
        filteredCoarse = [x for x in coarse if x.HasFundamentalData]
        sortedDollarVolume = sorted(filteredCoarse, key = lambda x: x.DollarVolume, reverse = True)
        coarseSymbols = [x.Symbol for x in sortedDollarVolume][:(self.nStocks * 2)]
        
        return coarseSymbols
        
    def SelectFine(self, algorithm, fine):
        
        ''' Select securities based on fundamental factor modelling '''
        
        sortedMarketCap = sorted(fine, key = lambda x: x.MarketCap, reverse = True)[:self.nStocks]

        # generate dictionary with factors -----------------------------------------------------------------------------
        fundamentalDataBySymbolDict = GetFundamentalDataDict(algorithm, sortedMarketCap, module = 'universe')
                    
        # make calculations to create long/short lists -----------------------------------------------------------------
        fineSymbols = list(fundamentalDataBySymbolDict.keys())
        calculations = MakeCalculations(algorithm, fineSymbols, self.lookback, Resolution.Daily, fundamentalDataBySymbolDict)
        
        # get long/short lists of symbols
        longs, shorts = GetLongShortLists(self, algorithm, calculations)
        finalSymbols = longs + shorts

        return finalSymbols
import pandas as pd
import numpy as np
from scipy.stats import skew, kurtosis

class SymbolData:
    
    ''' Perform calculations '''
    
    def __init__(self, symbol):
        self.Symbol = symbol
        
        self.fundamentalDataDict = {}
        
        self.momentum = None
        self.volatility = None
        self.skewness = None
        self.kurt = None
        self.positionVsHL = None
        self.meanOvernightReturns = None
    
    def CalculateFactors(self, history, fundamentalDataBySymbolDict):
        
        self.fundamentalDataDict = fundamentalDataBySymbolDict[self.Symbol]
        self.momentum = self.CalculateMomentum(history)
        self.volatility = self.CalculateVolatility(history)
        #self.skewness = self.CalculateSkewness(history)
        #self.kurt = self.CalculateKurtosis(history)
        #self.distanceVsHL = self.CalculateDistanceVsHL(history)
        #self.meanOvernightReturns = self.CalculateMeanOvernightReturns(history)
    
    def CalculateMomentum(self, history):
        
        closePrices = history.loc[str(self.Symbol)]['close']
        momentum = (closePrices[-1] / closePrices[-252]) - 1
        
        return momentum
        
    def CalculateVolatility(self, history):
        
        closePrices = history.loc[str(self.Symbol)]['close']
        returns = closePrices.pct_change().dropna()
        volatility = np.nanstd(returns, axis = 0)
        
        return volatility
        
    def CalculateSkewness(self, history):
        
        closePrices = history.loc[str(self.Symbol)]['close']
        returns = closePrices.pct_change().dropna()
        skewness = skew(returns)
        
        return skewness
        
    def CalculateKurtosis(self, history):
        
        closePrices = history.loc[str(self.Symbol)]['close']
        returns = closePrices.pct_change().dropna()
        kurt = kurtosis(returns)
        
        return kurt
        
    def CalculateDistanceVsHL(self, history):
        
        closePrices = history.loc[str(self.Symbol)]['close']
        annualHigh = max(closePrices)
        annualLow = min(closePrices)
        distanceVsHL = (closePrices[-1] - annualLow) / (annualHigh - annualLow)
        
        return distanceVsHL
        
    def CalculateMeanOvernightReturns(self, history):
        
        overnnightReturns = (history.loc[str(self.Symbol)]['open'] / history.loc[str(self.Symbol)]['close'].shift(1)) - 1
        meanOvernightReturns = np.nanmean(overnnightReturns, axis = 0)
        return meanOvernightReturns
            
    @property
    def factorsList(self):
        technicalFactors = [self.momentum, self.volatility]
        fundamentalFactors = [float(key) * value for key, value in self.fundamentalDataDict.items()]
        
        if all(v is not None for v in technicalFactors):
            return technicalFactors + fundamentalFactors
        else:
            return None
import matplotlib.pyplot as plt
import matplotlib.ticker as mtick
import scipy.stats as stats
import pandas as pd
import numpy as np

import seaborn as sns
sns.set_style('darkgrid')
pd.plotting.register_matplotlib_converters()

from datetime import timedelta

class FactorAnalysis:
    
    def __init__(self, qb, tickers, startDate, endDate, resolution):
        
        # add symbols
        symbols = [qb.AddEquity(ticker, resolution).Symbol for ticker in tickers]
        
        # get historical data at initialization ----------------------------------------------------------
        ohlcvDf = qb.History(symbols, startDate, endDate, resolution)
        # when using daily resolution, QuantConnect uses the date at midnight after the trading day
        # hence skipping Mondays and showing Saturdays. We avoid this by subtracting one day from the index
        ohlcvDf.index = ohlcvDf.index.set_levels(ohlcvDf.index.levels[1] - timedelta(1), level = 'time')
        # rename index to avoid issues with symbol object naming
        ohlcvDf.index = pd.MultiIndex.from_tuples([(qb.Securities[x[0]].Symbol.Value + '_', x[1]) for x in ohlcvDf.index],
                                                    names = ohlcvDf.index.names)
        self.ohlcvDf = ohlcvDf.dropna()
        
    def GetFactorsDf(self, fct = None):

        '''
        Description:
            Apply a function to a MultiIndex Dataframe of historical data
            Group on symbol first to get a ohlcv series per symbol, and apply a custom function to it
            in order to get a factor value per symbol and day
        Args:
            fct: Function to calculate the custom factor
        Returns:
            MultiIndex Dataframe (symbol/time indexes) with the factor values
        '''
        
        if fct is None:
            raise ValueError('fct arguments needs to be provided to calculate factors')
        
        # group by symbol to get a timeseries of historical data per symbol and apply CustomFactor function
        factorsDf = self.ohlcvDf.groupby('symbol', group_keys = False).apply(lambda x: fct(x)).dropna()
        factorsDf.columns = ['Factor_' + str(i + 1) for i in range(len(factorsDf.columns))]
        # sort indexes
        factorsDf = factorsDf.sort_index(level = ['symbol', 'time'])

        return factorsDf
    
    def GetStandardizedFactorsDf(self, factorsDf):
        
        '''
        Description:
            Winsorize and standardize factors
        Args:
            factorsDf: MultiIndex Dataframe (symbol/time indexes) with the factor values
        Returns:
            MultiIndex Dataframe (symbol/time indexes) with standardized factor values
        '''
        
        # winsorization
        winsorizedFactorsDf = factorsDf.apply(stats.mstats.winsorize, limits = [0.025, 0.025])
        # zscore standardization
        standardizedFactorsDf = winsorizedFactorsDf.apply(stats.zscore)
        
        return standardizedFactorsDf
    
    def GetCombinedFactorsDf(self, factorsDf, combinedFactorWeightsDict = None):
        
        '''
        Description:
            Create a combined factor as a linear combination of individual factors
        Args:
            factorsDf: MultiIndex Dataframe (symbol/time indexes) with the factor values
            combinedFactorWeightsDict: Dictionary with factor names and weights to calculate a combined factor
        Returns:
            MultiIndex Dataframe (symbol/time indexes) with the individual factors and the combined factor
        '''
        
        # make a deep copy of the DataFrame
        combinedFactorsDf = factorsDf.copy(deep = True)
        
        # calculate a combined factor
        if combinedFactorWeightsDict is None:
            return combinedFactorsDf
        elif not combinedFactorWeightsDict:
            combinedFactorsDf['Combined_Factor'] = combinedFactorsDf.sum(axis = 1)
        else:
            combinedFactorsDf['Combined_Factor'] = sum(combinedFactorsDf[key] * value
                                                       for key, value in combinedFactorWeightsDict.items())
        
        return combinedFactorsDf
    
    def GetFinalFactorsDf(self, fct = None, combinedFactorWeightsDict = None, standardize = True):

        '''
        Description:
            - Apply a function to a MultiIndex Dataframe of historical data
              Group on symbol first to get a ohlcv series per symbol, and apply a custom function to it
                  in order to get a factor value per symbol and day
            - If required, standardize the factors and remove potential outliers
            - If required, add a combined factor as a linear combination of individual factors
        Args:
            fct: Function to calculate the custom factor
            standardize: Boolean to standardize data
            combinedFactorWeightsDict: Dictionary with factor names and weights to calculate a combined factor
        Returns:
            MultiIndex Dataframe (symbol/time indexes) with the factor values
        '''
        
        # get factorsDf
        factorsDf = self.GetFactorsDf(fct)
        
        # standardize
        if standardize:
            factorsDf = self.GetStandardizedFactorsDf(factorsDf)
        
        # add combined factor
        if combinedFactorWeightsDict is not None:
            factorsDf = self.GetCombinedFactorsDf(factorsDf, combinedFactorWeightsDict)

        return factorsDf
    
    def GetPricesDf(self, field = 'close'):
    
        '''
        Description:
            Get a MultiIndex Dataframe of chosen field
        Args:
            field: open, high, low, close or volume
        Returns:
            MultiIndex Dataframe (symbol/time indexes) with the chosen field
        '''
        
        # select only chose field and turn into a dataframe
        pricesDf = self.ohlcvDf[field].to_frame()
        pricesDf.columns = ['price']
        # forward fill nas and after that drop rows with some nas left
        pricesDf = pricesDf.sort_index(level = ['symbol', 'time'])
        pricesDf = pricesDf.groupby('symbol').fillna(method = 'ffill').dropna()

        return pricesDf
    
    def GetFactorsPricesDf(self, factorsDf, field = 'close'):
    
        '''
        Description:
            Get a MultiIndex Dataframe (symbol/time indexes) with all the factors and chosen prices
        Args:
            factorsDf: MultiIndex Dataframe (symbol/time indexes) with the factor values
            field: open, high, low, close or volume
        Returns:
            MultiIndex Dataframe (symbol/time indexes) with all the factors and chosen prices
        '''

        # get the pricesDf
        pricesDf = self.GetPricesDf(field)
        
        # merge factorsDf and pricesDf and fill forward nans by symbol
        factorsPricesDf = pd.merge(factorsDf, pricesDf, how = 'right', left_index = True, right_index = True)
        factorsPricesDf = factorsPricesDf.sort_index(level = ['symbol', 'time'])
        factorsPricesDf = factorsPricesDf.groupby('symbol').fillna(method = 'ffill').dropna()

        return factorsPricesDf
    
    def GetFactorsForwardReturnsDf(self, factorsPricesDf, forwardPeriods = [1, 5, 21]):
    
        '''
        Description:
            Generate a MultiIndex Dataframe (symbol/time indexes) with all previous info plus forward returns
        Args:
            factorsPricesDf:  MultiIndex Dataframe (symbol/time indexes) with all the factors and chosen prices
            forwardPeriods: List of integers defining the different periods for forward returns
        Returns:
            MultiIndex Dataframe (symbol/time indexes) with the factor values and forward returns
        '''

        # make sure 1 day forward returns are calculated even if not provided by user
        if 1 not in forwardPeriods:
            forwardPeriods.append(1)
        
        # calculate forward returns per period
        for period in forwardPeriods:
            factorsPricesDf[str(period) + 'D'] = (factorsPricesDf.groupby('symbol', group_keys = False)
                                                    .apply(lambda x: x['price'].pct_change(period).shift(-period)))
        
        # drop column price
        factorsForwardReturnsDf = factorsPricesDf.dropna().drop('price', axis = 1)

        return factorsForwardReturnsDf
    
    def GetFactorQuantilesForwardReturnsDf(self, factorsDf, field = 'close',
                                            forwardPeriods = [1, 5, 21],
                                            factor = 'Factor_1', q = 5):
        
        '''
        Description:
            Create a MultiIndex Dataframe (symbol/time indexes) with the factor values,
            forward returns and the quantile groups
        Args:
            factorsDf: MultiIndex Dataframe (symbol/time indexes) with the factor values
            field: open, high, low, close or volume
            forwardPeriods: List of integers defining the different periods for forward returns
            factor: Chosen factor to create quantiles for
            q: Number of quantile groups
        Returns:
            MultiIndex Dataframe (symbol/time indexes) with the factor values, forward returns and the quantile groups
        '''
        
        # get factorsForwardReturnsDf
        factorsPricesDf = self.GetFactorsPricesDf(factorsDf, field)
        factorsForwardReturnsDf = self.GetFactorsForwardReturnsDf(factorsPricesDf, forwardPeriods)
        
        # reorder index levels to have time and then symbols so we can then create quantiles per day
        factorsForwardReturnsDf = factorsForwardReturnsDf.reorder_levels(['time', 'symbol'])
        factorsForwardReturnsDf = factorsForwardReturnsDf.sort_index(level = ['time', 'symbol'])
        
        # calculate quintiles given the chosen factor and rename columns
        factorsForwardReturnsDf['Quantile'] = factorsForwardReturnsDf[factor].groupby('time').apply(lambda x: pd.qcut(x, q, labels = False, duplicates = 'drop')).add(1)
        factorsForwardReturnsDf['Quantile'] = 'Group_' + factorsForwardReturnsDf['Quantile'].astype(str)
        
        # remove the other factor columns
        factorCols = [x for x in factorsForwardReturnsDf.columns if 'Factor' not in x or x == factor]
        factorQuantilesForwardReturnsDf = factorsForwardReturnsDf[factorCols]
        
        return factorQuantilesForwardReturnsDf
    
    def GetReturnsByQuantileDf(self, factorQuantilesForwardReturnsDf, forwardPeriod = 1, weighting = 'mean'):
    
        '''
        Description:
            Generate a SingleIndex Dataframe with period forward returns by quantile and time
        Args:
            factorQuantilesForwardReturnsDf: MultiIndex Dataframe (symbol/time indexes) with the factor values,
                                             forward returns and the quantile groups
            forwardPeriod: The period of forward returns
            weighting: The weighting to apply to the returns in each quantile after grouping:
                        - mean: Take the average of all the stock returns within each quantile
                        - factor: Take a factor-weighted return within each quantile
        Returns:
            SingleIndex Dataframe with period forward returns by quantile and time
        '''

        # we drop the symbols and convert to a MultiIndex Dataframe with Quantile and time as indexes and forward returns
        df = factorQuantilesForwardReturnsDf.droplevel(['symbol'])
        df.set_index('Quantile', append = True, inplace = True)
        df = df.reorder_levels(['Quantile', 'time'])
        df = df.sort_index(level = ['Quantile', 'time'])
        
        # get the column name for the factor and period
        factorCol = [x for x in df.columns if 'Factor' in x][0]
        periodCol = [str(forwardPeriod) + 'D'][0]
        
        if weighting == 'mean':
            df = df[[periodCol]]
            # group by Quantile and time and get the mean returns (equal weight across all stocks within each quantiles)
            returnsByQuantileDf = df.groupby(['Quantile', 'time']).mean()
        elif weighting == 'factor':
            relevantCols = [factorCol, periodCol]
            df = df[relevantCols]
            # group by Quantile and time and create a column with weights based on factor values
            df['Factor_Weights'] = (df.groupby(['Quantile', 'time'], group_keys = False)
                                    .apply(lambda x: x[factorCol].abs() / x[factorCol].abs().sum()))
            # group by Quantile and time and calculate the factor weighted average returns
            returnsByQuantileDf = (df.groupby(['Quantile', 'time'], group_keys = False)
                                    .apply(lambda x: (x['Factor_Weights'] * x[periodCol]).sum())).to_frame()

        # unstack to convert to SingleIndex Dataframe
        returnsByQuantileDf = returnsByQuantileDf.unstack(0).fillna(0)
        returnsByQuantileDf.columns = returnsByQuantileDf.columns.droplevel(0)
        returnsByQuantileDf.columns.name = None
        
        # finally keep every nth row to match with the forward period returns
        returnsByQuantileDf = returnsByQuantileDf.iloc[::forwardPeriod, :]

        return returnsByQuantileDf
    
    def GetMeanReturnsByQuantileDf(self, factorQuantilesForwardReturnsDf):
            
        '''
        Description:
            Generate a SingleIndex Dataframe with mean returns by quantile and time
        Args:
            factorQuantilesForwardReturnsDf: MultiIndex Dataframe (symbol/time indexes) with the factor values,
                                             forward returns and the quantile groups
        Returns:
            SingleIndex Dataframe with mean returns by quantile and time
        '''
    
        # remove factor columns, group by quantile and take the average return
        factorCol = [x for x in factorQuantilesForwardReturnsDf.columns if 'Factor' in x]
        quantileMeanReturn = factorQuantilesForwardReturnsDf.drop(factorCol, axis = 1).groupby('Quantile').mean()
        
        return quantileMeanReturn
    
    def GetPortfolioLongShortReturnsDf(self, returnsByQuantileDf, portfolioWeightsDict = None):
        
        '''
        Description:
            Generate a SingleIndex Dataframe with the returns of a Long-Short portfolio
        Args:
            returnsByQuantileDf: SingleIndex Dataframe with period forward returns by quantile and time
            portfolioWeightsDict: Dictionary with quantiles and weights to create a portfolio of returns
        Returns:
            SingleIndex Dataframe with the returns of Long-Short portfolio
        '''
        
        # if no portfolioWeightsDict are provided, create a default one
        # going 100% long top quintile and 100% short bottom quintile
        if portfolioWeightsDict is None:
            quantileGroups = sorted(list(returnsByQuantileDf.columns))
            topQuantile = quantileGroups[-1]
            bottomQuantile = quantileGroups[0]
            portfolioWeightsDict = {topQuantile: 1, bottomQuantile: -1}
        
        # we calculate the weighted average portfolio returns based on given weights for each quintile
        col = list(portfolioWeightsDict.keys())
        portfolioLongShortReturnsDf = returnsByQuantileDf.loc[: , col]
        portfolioLongShortReturnsDf[col[0]] = portfolioLongShortReturnsDf[col[0]] * portfolioWeightsDict[col[0]]
        portfolioLongShortReturnsDf[col[1]] = portfolioLongShortReturnsDf[col[1]] * portfolioWeightsDict[col[1]]
        portfolioLongShortReturnsDf['Strategy'] = portfolioLongShortReturnsDf.sum(axis = 1)
        portfolioLongShortReturnsDf = portfolioLongShortReturnsDf[['Strategy']]

        return portfolioLongShortReturnsDf
    
    def GetCumulativeReturnsDf(self, returnsDf):
        
        '''
        Description:
            Convert a DataFrame of returns into a DataFrame of cumulative returns
        Args:
            returnsDf: SingleIndex Dataframe with returns
        Returns:
            SingleIndex Dataframe with cumulative returns
        '''
        
        cumulativeReturnsDf = returnsDf.add(1).cumprod().add(-1)

        return cumulativeReturnsDf

    # ploting functions -----------------------------------------------------------------------------------------
    
    def PlotFactorsCorrMatrix(self, factorsDf):
        
        '''
        Description:
            Plot the factors correlation matrix
        Args:
            factorsDf: MultiIndex Dataframe (symbol/time indexes) with the factor values
        Returns:
            Plot the factors correlation matrix
        '''

        corrMatrix = round(factorsDf.corr(), 2)
        
        nCol = len(list(factorsDf.columns))
        plt.subplots(figsize = (nCol, nCol))
        sns.heatmap(corrMatrix, annot = True)
        
        plt.show()
        
    def PlotHistograms(self, factorsDf):
        
        '''
        Description:
            Plot the histogram for each factor
        Args:
            factorsDf: MultiIndex Dataframe (symbol/time indexes) with the factor values
        Returns:
            Plot the histogram for each factor
        '''
        
        nCol = len(list(factorsDf.columns))
        factorsDf.hist(figsize = (nCol * 3, nCol * 2), bins = 50)
        
        plt.show()
        
    def PlotBoxPlotQuantilesCount(self, factorQuantilesForwardReturnsDf):
                
        '''
        Description:
            Plot a box plot with the distributions of number of stocks in each quintile.
            The objective is to make sure each quintile has an almost equal number of stocks most of the time
        Args:
            factorQuantilesForwardReturnsDf: MultiIndex Dataframe (symbol/time indexes) with the factor values,
                                             forward returns and the quantile groups
        Returns:
            Plot a box plot with the distributions of number of stocks in each quintile
        '''
        
        factorCol = [x for x in factorQuantilesForwardReturnsDf.columns if 'Factor' in x]
        df = factorQuantilesForwardReturnsDf.groupby(['Quantile', 'time'])[factorCol].count()
        df = df.unstack(0)
        df.columns = df.columns.droplevel(0)
        df.name = None
        
        ax = sns.boxplot(data = df, width = 0.5, palette = "colorblind", orient = 'h')
        ax.set_title('Distribution Of Number Of Assets Within Quintiles')
        
        plt.show()
    
    def PlotMeanReturnsByQuantile(self, factorQuantilesForwardReturnsDf):
        
        '''
        Description:
            Plot the mean return for each quantile group and forward return period
        Args:
            factorQuantilesForwardReturnsDf: MultiIndex Dataframe (symbol/time indexes) with the factor values,
                                             forward returns and the quantile groups
        Returns:
            Plot with the mean return for each quantile group and forward return period
        '''
        
        meanReturnsByQuantileDf = self.GetMeanReturnsByQuantileDf(factorQuantilesForwardReturnsDf)
        # plot
        ax = meanReturnsByQuantileDf.plot(kind = 'bar', figsize = (12, 5))
        ax.set_title('Mean Returns By Quantile Group And Forward Period Return', fontdict = {'fontsize': 15})
        ax.yaxis.set_major_formatter(mtick.PercentFormatter(1.0))
        
        plt.show()    
        
    def PlotCumulativeReturnsByQuantile(self, factorQuantilesForwardReturnsDf,
                                        forwardPeriod = 1, weighting = 'mean'):
        
        '''
        Description:
            Plot cumulative returns per quantile group
        Args:
            factorQuantilesForwardReturnsDf: MultiIndex Dataframe (symbol/time indexes) with the factor values,
                                             forward returns and the quantile groups
            forwardPeriod: The period of forward returns
            weighting: The weighting to apply to the returns in each quantile after grouping:
                        - mean: Take the average of all the stock returns within each quantile
                        - factor: Take a factor-weighted return within each quantile
        Returns:
            Plot with the cumulative returns per quantile group
        '''
        
        # get returns by quantile
        returnsByQuantileDf = self.GetReturnsByQuantileDf(factorQuantilesForwardReturnsDf, forwardPeriod, weighting)
        cumulativeReturnsByQuantileDf = self.GetCumulativeReturnsDf(returnsByQuantileDf)
        
        # take logarithm for better visualization
        cumulativeReturnsByQuantileDf = np.log(1 + cumulativeReturnsByQuantileDf)
        
        # get the relevant columns
        colTop = cumulativeReturnsByQuantileDf.iloc[:, [-1]].columns[0]
        colBottom = cumulativeReturnsByQuantileDf.iloc[:, [0]].columns[0]
        colMiddle = cumulativeReturnsByQuantileDf.drop([colTop, colBottom], axis = 1).columns
        
        # plot
        fig, ax = plt.subplots(figsize = (12, 5))
        ax.plot(cumulativeReturnsByQuantileDf[colBottom], color = 'red', linewidth = 2)
        ax.plot(cumulativeReturnsByQuantileDf[colMiddle], alpha = 0.3)
        ax.plot(cumulativeReturnsByQuantileDf[colTop], color = 'green', linewidth = 2)
        # formatting
        ax.axhline(y = 0, color = 'black', linestyle = '--', linewidth = 0.5)
        ax.set_title('Cumulative Log-Returns By Quantile Group', fontdict = {'fontsize': 15})
        ax.yaxis.set_major_formatter(mtick.PercentFormatter(1.0))
        ax.legend(cumulativeReturnsByQuantileDf.columns, loc = 'best')
        
        plt.show()
        
    def PlotPortfolioLongShortCumulativeReturns(self, factorQuantilesForwardReturnsDf,
                                                forwardPeriod = 1, weighting = 'mean',
                                                portfolioWeightsDict = None):

        '''
        Description:
            Plot cumulative returns for a long-short portfolio
        Args:
            factorQuantilesForwardReturnsDf: MultiIndex Dataframe (symbol/time indexes) with the factor values,
                                             forward returns and the quantile groups
            forwardPeriod: The period of forward returns
            weighting: The weighting to apply to the returns in each quantile after grouping:
                        - mean: Take the average of all the stock returns within each quantile
                        - factor: Take a factor-weighted return within each quantile
        Returns:
            Plot cumulative returns for a long-short portfolio
        '''
        
        # get returns by quantile
        returnsByQuantileDf = self.GetReturnsByQuantileDf(factorQuantilesForwardReturnsDf, forwardPeriod, weighting)
        # calculate returns for a long-short portolio
        portfolioLongShortReturnsDf = self.GetPortfolioLongShortReturnsDf(returnsByQuantileDf, portfolioWeightsDict)
        portfolioLongShortCumulativeReturnsDf = self.GetCumulativeReturnsDf(portfolioLongShortReturnsDf)
        
        # prepare plot
        fig, ax = plt.subplots(figsize = (12, 5))
        
        # plot portfolio
        colPortfolio = portfolioLongShortCumulativeReturnsDf.iloc[:, [0]].columns[0]
        ax.plot(portfolioLongShortCumulativeReturnsDf[colPortfolio], color = 'black', linewidth = 2)
            
        if len(portfolioLongShortCumulativeReturnsDf.columns) > 1:
            colFactors = portfolioLongShortCumulativeReturnsDf.iloc[:, 1:].columns
            # plot factors
            ax.plot(portfolioLongShortCumulativeReturnsDf[colFactors], alpha = 0.3)
            
        # formatting
        ax.axhline(y = 0, color = 'black', linestyle = '--', linewidth = 0.5)
        ax.set_title('Cumulative Returns Long-Short Portfolio', fontdict = {'fontsize': 15})
        ax.yaxis.set_major_formatter(mtick.PercentFormatter(1.0))
        ax.legend(portfolioLongShortCumulativeReturnsDf.columns, loc = 'best')
        
        plt.show()
        
    def PlotIC(self, factorQuantilesForwardReturnsDf):
        
        '''
        Description:
            Plot the Information Coefficient (Spearman Rank Correlation) for different periods along with a moving average
        Args:
            factorQuantilesForwardReturnsDf: MultiIndex Dataframe (symbol/time indexes) with the factor values,
                                             forward returns and the quantile groups
        Returns:
            Plot of the Information Coefficient (Spearman Rank Correlation) for different periods along with a moving average
        '''
        
        # get the forward periods and factor columns
        forwardPeriods = [int(x.split('D', 1)[0]) for x in factorQuantilesForwardReturnsDf.columns if 'D' in x]
        factorCol = [x for x in factorQuantilesForwardReturnsDf.columns if 'Factor' in x]
        
        # iterate over the periods
        for period in forwardPeriods:
            col = str(period) + 'D'
            # calculate the spearman rank coefficient for each day between the factor values and forward returns
            icDf = (factorQuantilesForwardReturnsDf.groupby('time')
                    .apply(lambda x: stats.spearmanr(x[factorCol], x[col])[0]).to_frame().dropna())
            icDf.columns = ['IC']
            # apply a moving average for smoothing
            icDf['21D Moving Average'] = icDf.rolling(21).apply(lambda x: np.mean(x))
            
            # plot
            fig, ax = plt.subplots(figsize = (12, 5))
            ax.plot(icDf['IC'], alpha = 0.5)
            ax.plot(icDf['21D Moving Average'])
            ax.axhline(y = 0, color = 'black', linestyle = '--', linewidth = 0.5)

            mu = icDf['IC'].mean()
            sigma = icDf['IC'].std()
            textstr = '\n'.join((
                        r'$\mu=%.2f$' % (mu, ),
                        r'$\sigma=%.2f$' % (sigma, )))
            props = dict(boxstyle = 'round', facecolor = 'white', alpha = 0.5)
            ax.text(0.05, 0.95, textstr, transform = ax.transAxes, fontsize = 14,
                    verticalalignment = 'top', bbox = props)

            ax.set_title(col + ' Forward Return Information Coefficient (IC)', fontdict = {'fontsize': 15})
            ax.legend(icDf.columns, loc = 'upper right')
            
            plt.show()
            
    # run full factor analysis --------------------------------------------------------------------------------------
    
    def RunFactorAnalysis(self, factorQuantilesForwardReturnsDf, forwardPeriod = 1,
                        weighting = 'mean', portfolioWeightsDict = None, makePlots = True):
                                
        '''
        Description:
            Run all needed functions and generate relevant DataFrames and plots for analysis
        Args:
            factorQuantilesForwardReturnsDf: MultiIndex Dataframe (symbol/time indexes) with the factor values,
                                             forward returns and the quantile groups
            forwardPeriod: The period of forward returns
            weighting: The weighting to apply to the returns in each quantile after grouping:
                        - mean: Take the average of all the stock returns within each quantile
                        - factor: Take a factor-weighted return within each quantile
            portfolioWeightsDict: Dictionary with quantiles and weights to create a portfolio of returns
        Returns:
            Plots for factor analysis
        '''
        
        # plotting
        if makePlots:
            self.PlotMeanReturnsByQuantile(factorQuantilesForwardReturnsDf)
            self.PlotCumulativeReturnsByQuantile(factorQuantilesForwardReturnsDf)
            self.PlotPortfolioLongShortCumulativeReturns(factorQuantilesForwardReturnsDf)
            self.PlotIC(factorQuantilesForwardReturnsDf)
        
        # keep DataFrames
        self.returnsByQuantileDf = self.GetReturnsByQuantileDf(factorQuantilesForwardReturnsDf, forwardPeriod, weighting)
        self.cumulativeReturnsByQuantileDf = self.GetCumulativeReturnsDf(self.returnsByQuantileDf)
        self.portfolioLongShortReturnsDf = self.GetPortfolioLongShortReturnsDf(self.returnsByQuantileDf, portfolioWeightsDict)
        self.portfolioLongShortCumulativeReturnsDf = self.GetCumulativeReturnsDf(self.portfolioLongShortReturnsDf)