“Universe: NYSE, AMEX, NASDAQ stocks (share code 10 or 11), excluding <$5 prices. Quarterly FSCORE (0-9) constructed. Match with monthly reversal data. Long past losers (7-9 FSCORE), short past winners (0-3). Equal weighted, monthly rebalancing.”

I. STRATEGY IN A NUTSHELL

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), 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 favourable fundamentals (7-9) and simultaneously a short position in past winners with unfavourable fundamentals (0-3). The strategy is equally weighted and rebalanced monthly.

II. ECONOMIC RATIONALE

There are three reasons to use FSCORE. Firstly, FSCORE is a comprehensive metric of a firm’s fundamental strength, because this score synthesizes information from nine signals along three dimensions of a firm’s financial performance (profitability, change in financial leverage and liquidity, and change in operational efficiency). Secondly, the fundamental information is gathered directly from the financial statements, which obviates the measurement error problem. And lastly, FSCORE is a nonparametric measure, compared with a parametric approach, FSCORE is more robust and helps to reduce concerns over potential estimation biases. Results support the hypothesis that short-term reversals are influenced by both noise trading and investor underreaction to fundamental information. Also results from regression analysis suggest that both noise trading and fundamental information significantly influence stock returns on the short horizon. No doubt, there is a conclusion that investor underreaction to fundamental information coupled with noise trading can explain the observed empirical patterns in short-term reversals. Moreover, results indicate that the bid-ask spread cannot be the main source of the profitability for short-term reversals, and the results are not particularly sensitive to alternative definitions of fundamental strength. Last but not least, simple short-term reversal and industry-adjusted reversal strategies fail to be profitable in the presence of transaction costs; however, fundamental anchored reversal strategies are economically profitable even in the presence of transaction costs.

III. SOURCE PAPER

Fundamental Strength and Short-Term Return Reversal [Click to Open PDF]

<Abstract>

We document that the fundamental strength (FSCORE) of a firm exerts a significant influence on the performance of short-term reversal strategies. Past losers with strong fundamentals significantly outperform past winners with weak fundamentals. Our FSCORE approach is complementary to Da et al. (2014) cash flow news metrics based on analysts’ forecast revisions in that many firms do not have analyst following. Our approach also seems capable of capturing the lagged effects from past fundamental news shocks. After controlling for fundamental strength, we find that investor sentiment plays a more dominant role than do liquidity shocks in explaining return reversal.

IV. BACKTEST PERFORMANCE

Annualised Return12.01%
Volatility20.61%
Beta-0.48
Sharpe Ratio0.15
Sortino Ratio0.195
Maximum Drawdown51.2%
Win Rate45%

V. FULL PYTHON CODE

from AlgoLib 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(XXX):

    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.AdjustedPrice > 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('.'))

Leave a Reply

Discover more from Quant Buffet

Subscribe now to keep reading and get access to the full archive.

Continue reading