Overall Statistics
Total Orders
110762
Average Win
0.06%
Average Loss
-0.04%
Compounding Annual Return
4.029%
Drawdown
49.400%
Expectancy
0.051
Start Equity
100000
End Equity
261012.51
Net Profit
161.013%
Sharpe Ratio
0.125
Sortino Ratio
0.161
Probabilistic Sharpe Ratio
0.000%
Loss Rate
55%
Win Rate
45%
Profit-Loss Ratio
1.35
Alpha
0.042
Beta
-0.487
Annual Standard Deviation
0.171
Annual Variance
0.029
Information Ratio
-0.073
Tracking Error
0.282
Treynor Ratio
-0.044
Total Fees
$10156.65
Estimated Strategy Capacity
$71000000.00
Lowest Capacity Asset
ESLTF R735QTJ8XC9X
Portfolio Turnover
7.98%
from AlgorithmImports import *

class StockData():
    def __init__(self):
        self.ROA = 0
        self.Leverage = 0
        self.Liquidity = 0
        self.Equity_offering = 0
        self.Gross_margin = 0
        self.Turnover = 0
    
    def Update(self, ROA, leverage, liquidity, eq_offering, gross_margin, turnover):
        self.ROA = ROA
        self.Leverage = leverage
        self.Liquidity = liquidity
        self.Equity_offering = eq_offering
        self.Gross_margin = gross_margin
        self.Turnover = turnover

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) -> bool:
        return self.Price.IsReady
        
    def performance(self, values_to_skip = 0) -> float:
        closes = [x for x in self.Price][values_to_skip:]
        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"))
# https://quantpedia.com/strategies/combining-fundamental-fscore-and-equity-short-term-reversals/
#
# The investment universe consists of common stocks (share code 10 or 11) listed in NYSE, AMEX, and NASDAQ exchanges. 
# Stocks with prices less than $5 at the end of the formation period are excluded.
# The range of FSCORE is from zero to nine points. Each signal is equal to one (zero) point if the signal indicates a positive
# (negative) financial performance. A firm scores one point if it has realized a positive return-on-assets (ROA), a positive 
# cash flow from operations, a positive change in ROA, a positive difference between net income from operations (Accrual), 
# a decrease in the ratio of long-term debt to total assets, a positive change in the current ratio, no-issuance of new common
# equity, a positive change in gross margin ratio and lastly a positive change in asset turnover ratio. Firstly, construct a quarterly
# FSCORE using the most recently available quarterly financial statement information.
# Monthly reversal data are matched each month with a most recently available quarterly FSCORE. The firm is classified as a fundamentally
# strong firm if the firm’s FSCORE is greater than or equal to seven (7-9), fundamentally middle firm (4-6) and fundamentally weak firm (0-3).
# Secondly, identify the large stocks subset – those in the top 40% of all sample stocks in terms of market capitalization 
# at the end of formation month t. After that, stocks are sorted on the past 1-month returns and firm’s most recently available quarterly FSCORE.
# Take a long position in past losers with favorable fundamentals (7-9) and simultaneously a short position in past winners with unfavorable 
# fundamentals (0-3). The strategy is equally weighted and rebalanced monthly.
#
# QC implementation changes:
#   - Instead of all listed stock, we select 3000 largest stocks traded on NYSE, AMEX, or NASDAQ.

from AlgorithmImports import *
from typing import List, Dict
from numpy import floor, isnan
from functools import reduce
from pandas.core.frame import DataFrame
import data_tools

class CombiningFSCOREShortTermReversals(QCAlgorithm):

    def Initialize(self):
        self.SetStartDate(2000, 1, 1)
        self.SetCash(100000)
        
        self.exchange_codes:List[str] = ['NYS', 'NAS', 'ASE']

        self.financial_statement_names:List[str] = [
            'EarningReports.BasicAverageShares.ThreeMonths',
            'EarningReports.BasicEPS.TwelveMonths',
            'OperationRatios.ROA.ThreeMonths',
            'OperationRatios.GrossMargin.ThreeMonths',
            'FinancialStatements.CashFlowStatement.CashFlowFromContinuingOperatingActivities.ThreeMonths',
            'FinancialStatements.IncomeStatement.NormalizedIncome.ThreeMonths',
            'FinancialStatements.BalanceSheet.LongTermDebt.ThreeMonths',
            'FinancialStatements.BalanceSheet.TotalAssets.ThreeMonths',
            'FinancialStatements.BalanceSheet.OrdinarySharesNumber.ThreeMonths',
            'FinancialStatements.IncomeStatement.TotalRevenueAsReported.ThreeMonths',
            'ValuationRatios.PERatio',
            'OperationRatios.CurrentRatio.ThreeMonths',
        ]

        self.SetSecurityInitializer(lambda x: x.SetMarketPrice(self.GetLastKnownPrice(x)))
        
        self.leverage:int = 10
        self.min_share_price:int = 5
        self.period = 21

        self.long:List[Symbol] = []
        self.short:List[Symbol] = []
        
        self.stock_data:Dict[Symbol, data_tools.StockData] = {}
        self.data:Dict[Symbol, data_tools.SymbolData] = {}

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

        self.fundamental_count:int = 3000
        self.fundamental_sorting_key = lambda x: x.MarketCap
        
        self.selection_flag = False
        self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
        self.UniverseSettings.Resolution = Resolution.Daily
        self.AddUniverse(self.FundamentalSelectionFunction)
        self.Schedule.On(self.DateRules.MonthEnd(market), self.TimeRules.AfterMarketOpen(market), self.Selection)
   
    def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
        for security in changes.AddedSecurities:
            security.SetFeeModel(data_tools.CustomFeeModel())
            security.SetLeverage(self.leverage)

    def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
        # Update the rolling window every day.
        for stock in fundamental:
            symbol:Symbol = stock.Symbol

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

        if not self.selection_flag:
            return Universe.Unchanged

        selected:List[Fundamental] = [
            x for x in fundamental if x.HasFundamentalData and x.Market == 'usa' and x.Price > self.min_share_price \
            and all((not isnan(self.rgetattr(x, statement_name)) and self.rgetattr(x, statement_name) != 0) for statement_name in self.financial_statement_names) \
            and x.SecurityReference.ExchangeId in self.exchange_codes
        ]

        if len(selected) > self.fundamental_count:
            selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]

        # Warmup price rolling windows.
        for stock in selected:
            symbol:Symbol = stock.Symbol

            if symbol in self.data:
                continue
            
            self.data[symbol] = data_tools.SymbolData(symbol, self.period)
            history:DataFrame = self.History(symbol, self.period, Resolution.Daily)
            if history.empty:
                self.Log(f"Not enough data for {symbol} yet.")
                continue
            closes:Series = history.loc[symbol].close
            for time, close in closes.items():
                self.data[symbol].update(close)                  

        # BM sorting
        sorted_by_market_cap:List[Fundamental] = sorted(selected, key = lambda x: x.MarketCap, reverse = True)
        lenght:int = int((len(sorted_by_market_cap) / 100) * 40)
        top_by_market_cap:List[Fundamental] = [x for x in sorted_by_market_cap[:lenght]]

        fine_symbols:List[Symbol] = [x.Symbol for x in top_by_market_cap]
        
        score_performance:Dict[Symbol, Tuple[float]] = {}
        
        for stock in top_by_market_cap:
            symbol:Symbol = stock.Symbol
            
            if not self.data[symbol].is_ready():
                continue

            if symbol not in self.stock_data:
                self.stock_data[symbol] = data_tools.StockData()   # Contains latest data.
                
            roa:float = stock.OperationRatios.ROA.ThreeMonths
            cfo:float = stock.FinancialStatements.CashFlowStatement.CashFlowFromContinuingOperatingActivities.ThreeMonths
            leverage:float = stock.FinancialStatements.BalanceSheet.LongTermDebt.ThreeMonths / stock.FinancialStatements.BalanceSheet.TotalAssets.ThreeMonths
            liquidity:float = stock.OperationRatios.CurrentRatio.ThreeMonths
            equity_offering:float = stock.FinancialStatements.BalanceSheet.OrdinarySharesNumber.ThreeMonths
            gross_margin:float = stock.OperationRatios.GrossMargin.ThreeMonths
            turnover:float = stock.FinancialStatements.IncomeStatement.TotalRevenueAsReported.ThreeMonths / stock.FinancialStatements.BalanceSheet.TotalAssets.ThreeMonths
            
            # Check if data has previous year's data ready.
            stock_data = self.stock_data[symbol]
            if (stock_data.ROA == 0) or (stock_data.Leverage == 0) or (stock_data.Liquidity == 0) or (stock_data.Equity_offering == 0) or (stock_data.Gross_margin == 0) or (stock_data.Turnover == 0):
                stock_data.Update(roa, leverage, liquidity, equity_offering, gross_margin, turnover)
                continue

            score:int = 0

            if roa > 0:
                score += 1
            if cfo > 0:
                score += 1
            if roa > stock_data.ROA:   # ROA change is positive
                score += 1
            if cfo > roa:
                score += 1
            if leverage < stock_data.Leverage:
                score += 1
            if liquidity > stock_data.Liquidity:
                score += 1
            if equity_offering < stock_data.Equity_offering:
                score += 1
            if gross_margin > stock_data.Gross_margin:
                score += 1
            if turnover > stock_data.Turnover:
                score += 1
            
            score_performance[symbol] = (score, self.data[symbol].performance())
            
            # Update new (this year's) data.
            stock_data.Update(roa, leverage, liquidity, equity_offering, gross_margin, turnover)
        
        # Clear out not updated data.
        for symbol in self.stock_data:
            if symbol not in fine_symbols:
                self.stock_data[symbol] = data_tools.StockData()
        
        # Performance sorting and F score sorting.
        self.long = [x[0] for x in score_performance.items() if x[1][0] >= 7 and x[1][1] < 0]
        self.short = [x[0] for x in score_performance.items() if x[1][0] <= 3 and x[1][1] > 0]

        return self.long + self.short

    def OnData(self, data: Slice) -> None:
        if not self.selection_flag:
            return
        self.selection_flag = False
        
        # Trade execution.
        targets:List[PortfolioTarget] = []
        for i, portfolio in enumerate([self.long, self.short]):
            for symbol in portfolio:
                if symbol in data and data[symbol]:
                    targets.append(PortfolioTarget(symbol, ((-1) ** i) / len(portfolio)))
        
        self.SetHoldings(targets, True)

        self.long.clear()
        self.short.clear()

    def Selection(self) -> None:
        self.selection_flag = True

    # https://gist.github.com/wonderbeyond/d293e7a2af1de4873f2d757edd580288
    def rgetattr(self, obj, attr, *args):
        def _getattr(obj, attr):
            return getattr(obj, attr, *args)
        return reduce(_getattr, [obj] + attr.split('.'))