Overall Statistics
Total Orders
13669
Average Win
0.07%
Average Loss
-0.04%
Compounding Annual Return
13.700%
Drawdown
25.900%
Expectancy
0.733
Start Equity
100000
End Equity
715437.58
Net Profit
615.438%
Sharpe Ratio
0.691
Sortino Ratio
0.632
Probabilistic Sharpe Ratio
15.657%
Loss Rate
36%
Win Rate
64%
Profit-Loss Ratio
1.70
Alpha
0.023
Beta
0.658
Annual Standard Deviation
0.123
Annual Variance
0.015
Information Ratio
-0.091
Tracking Error
0.093
Treynor Ratio
0.129
Total Fees
$1174.64
Estimated Strategy Capacity
$70000000.00
Lowest Capacity Asset
ALG R735QTJ8XC9X
Portfolio Turnover
1.47%
# https://quantpedia.com/strategies/esg-factor-momentum-strategy/
#
# The investment universe consists of stocks in the MSCI World Index. Paper uses MSCI ESG Ratings as the ESG database.
# The ESG Momentum strategy is built by overweighting, relative to the MSCI World Index, companies that increased their
# ESG ratings most during the recent past and underweight those with decreased ESG ratings, where the increases and decreases
# are based on a 12-month ESG momentum. The paper uses the Barra Global Equity Model (GEM3) for portfolio construction with 
# constraints that can be found in Appendix 2. Therefore, this strategy is very specific, but we aim to present the idea, not 
# the portfolio construction. The strategy is rebalanced monthly.
#
# QC implementation changes:
#   - Universe consists of ~700 stocks with ESG score data.

#region imports
from AlgorithmImports import *
from numpy import floor
from collections import deque
from typing import List, Dict, Tuple
from dataclasses import dataclass
from decimal import *
#endregion

class ESGFactorMomentumStrategy(QCAlgorithm):

    def Initialize(self) -> None:
        self.SetStartDate(2009, 6, 1)
        self.SetCash(100_000)
        
        # Decile weighting.
        # True - Value weighted
        # False - Equally weighted
        self.value_weighting: bool = True
        
        self.esg_data: Security = self.AddData(ESGData, 'ESG', Resolution.Daily)
        self.tickers: List[str] = []
        
        self.holding_period: int = 3
        self.managed_queue: List[RebalanceQueueItem] = []
        self.quantile: int = 10
        self.leverage: int = 10

        # Monthly ESG decile data.
        self.esg: Dict[str, RollingWindow[float]] = {}
        self.period: int = 14
        
        self.latest_price: Dict[Symbol, float] = {}
        
        self.selection_flag: bool = False
        self.UniverseSettings.Leverage = self.leverage
        self.UniverseSettings.Resolution = Resolution.Daily
        self.AddUniverse(self.FundamentalSelectionFunction)
        self.settings.daily_precise_end_time = False
        self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
    
    def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
        for security in changes.AddedSecurities:
            security.SetFeeModel(CustomFeeModel())
    
    def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
        if not self.selection_flag:
            return Universe.Unchanged

        self.latest_price.clear()
        
        selected: List[Fundamental] = [
            x for x in fundamental 
            if x.MarketCap != 0
            and (x.Symbol.Value).lower() in self.tickers]
        
        for stock in selected:
            symbol: Symbol = stock.Symbol
            self.latest_price[symbol] = stock.AdjustedPrice

        momentum: Dict[Fundamental, float] = {}
        
        # Momentum calc.
        for stock in selected:
            symbol: Symbol = stock.Symbol
            ticker: str = symbol.Value
            # ESG data for 14 months is ready.
            if ticker in self.esg and self.esg[ticker].IsReady:
                esg_data: List[float] = [x for x in self.esg[ticker]]
                
                esg_decile_2_months_ago: float = esg_data[1]
                esg_decile_14_months_ago: float = esg_data[13]
                
                if esg_decile_14_months_ago != 0 and esg_decile_2_months_ago != 0:
                    # Momentum as difference.
                    # momentum_ = esg_decile_2_months_ago - esg_decile_14_months_ago
                    
                    # Momentum as ratio.
                    momentum_: float = (esg_decile_2_months_ago / esg_decile_14_months_ago) - 1
                    
                    # Store momentum/market cap pair.
                    momentum[stock] = momentum_
        
        if len(momentum) <= self.quantile:
            return Universe.Unchanged

        # Momentum sorting.
        sorted_by_momentum: List[Tuple[Fundamental, float]] = sorted(momentum.items(), key = lambda x: x[1], reverse = True)
        quantile: int = int(len(sorted_by_momentum) / self.quantile)
        long: List[Fundamental] = [x[0] for x in sorted_by_momentum[:quantile]]
        short: List[Fundamental] = [x[0] for x in sorted_by_momentum[-quantile:]]

        if len(long) == 0 or len(short) == 0:
            return Universe.Unchanged

        weights: List[Tuple[Symbol, float]] = []
        # ew
        if not self.value_weighting:
            for i, portfolio in enumerate([long, short]):
                for stock in portfolio:
                    w: float = self.Portfolio.TotalPortfolioValue / self.holding_period / len(portfolio)
                    weights.append((stock.Symbol, ((-1) ** i) * floor(w / self.latest_price[stock.Symbol])))

        # vw
        else:
            for i, portfolio in enumerate([long, short]):
                mc_sum: float = sum(list(map(lambda x: x.MarketCap, portfolio)))
                for stock in portfolio:
                    w: float = self.Portfolio.TotalPortfolioValue / self.holding_period
                    weights.append((stock.Symbol, ((-1) ** i) * floor((w * (stock.MarketCap / mc_sum))) / self.latest_price[stock.Symbol]))

        self.managed_queue.append(RebalanceQueueItem(weights))
        
        return [x.Symbol for x in long + short]
    
    def OnData(self, slice: Slice) -> None:
        new_data_arrived: bool = False

        custom_data_last_update_date: datetime.date = ESGData.get_last_update_date()

        if self.esg_data.get_last_data() and self.time.date() > custom_data_last_update_date:
            self.liquidate()
            return

        if slice.contains_key('ESG') and slice['ESG']:
            # Store universe tickers.
            if len(self.tickers) == 0:
                # TODO '_typename' in storage dictionary?
                self.tickers = [x.Key for x in self.esg_data.GetLastData().GetStorageDictionary()][1:-1]
        
            # Store history for every ticker.
            for ticker in self.tickers:
                ticker_u: str = ticker.upper()
                if ticker_u not in self.esg:
                    self.esg[ticker_u] = RollingWindow[float](self.period)
                
                decile: float = self.esg_data.GetLastData()[ticker]
                self.esg[ticker_u].Add(decile)
                
                # trigger selection after new esg data arrived.
                if not self.selection_flag:
                    new_data_arrived = True
        
        if new_data_arrived:
            self.selection_flag = True
            return
        
        if not self.selection_flag:
            return
        self.selection_flag = False

        # Trade execution
        remove_item: Union[None, RebalanceQueueItem] = None
        
        # Rebalance portfolio
        for item in self.managed_queue:
            if item.holding_period == self.holding_period:
                for symbol, quantity in item.symbol_q:
                    self.MarketOrder(symbol, -quantity)
                            
                remove_item = item
                
            elif item.holding_period == 0:
                open_symbol_q: List[RebalanceQueueItem] = []
                
                for symbol, quantity in item.symbol_q:
                    if quantity >= 1:
                        if slice.contains_key(symbol) and slice[symbol]:
                            self.MarketOrder(symbol, quantity)
                            open_symbol_q.append((symbol, quantity))
                            
                # Only opened orders will be closed        
                item.symbol_q = open_symbol_q
                
            item.holding_period += 1
            
        if remove_item:
            self.managed_queue.remove(remove_item)

@dataclass
class RebalanceQueueItem():
    # symbol/quantity collections
    symbol_q: List[Tuple[Symbol, float]] 
    holding_period: int = 0

# ESG data.
class ESGData(PythonData):
    _last_update_date:datetime.date = datetime(1,1,1).date()

    @staticmethod
    def get_last_update_date() -> datetime.date:
       return ESGData._last_update_date

    def __init__(self):
        self.tickers = []
    
    def GetSource(self, config: SubscriptionDataConfig, date: datetime, isLiveMode: bool) -> SubscriptionDataSource:
        return SubscriptionDataSource("data.quantpedia.com/backtesting_data/economic/esg_deciles_data.csv", SubscriptionTransportMedium.RemoteFile, FileFormat.Csv)
    
    def Reader(self, config: SubscriptionDataConfig, line: str, date: datetime, isLiveMode: bool) -> BaseData:
        data = ESGData()
        data.Symbol = config.Symbol
        
        if not line[0].isdigit():
            self.tickers = [x for x in line.split(';')][1:]
            return None
            
        split = line.split(';')
        
        data.Time = datetime.strptime(split[0], "%Y-%m-%d") + timedelta(days=1)

        index = 1
        for ticker in self.tickers:
            data[ticker] = float(split[index])
            index += 1
            
        data.Value = float(split[1])

        if data.Time.date() > ESGData._last_update_date:
            ESGData._last_update_date = data.Time.date()

        return data
        
# 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"))