QuantRocket logo
Disclaimer


Fundamental Factors › Lesson 12: Multi-Factor Scores


Multi-Factor Scores¶

Often it is desirable to incorporate multiple factors into portfolio allocation decisions. There are different ways to combine factors. For example, you could select the top quantile of stocks based on factor $A$, then within that quantile select the top 100 stocks ranked by factor $B$. Another way, which we will look at in this notebook, is to combine multiple factors into a single score. The Altman Z-Score is an example of a multi-factor score. It assigns different weights to various fundamental ratios and sums the weighted ratios into a score.

Another well-known multi-factor score is the Piotroski F-Score, which measures firm quality. Unlike the Altman Z-Score which sums weighted ratios, the Piotroski F-Score assigns companies 1 point for each of 9 true or false conditions, such as whether this year's return on assets is higher than last year's, resulting in a possible score of 0-9.

In this notebook, we'll explore a simpler example of a multi-factor score based on boolean conditions. We will assign companies 1 point if they are "zombies" having interest coverage ratios below 1, and 1 point if their Altman Z-Score is in the distress zone. This will result in a possible score of 0 (if neither condition is met), 1, or 2 (if both conditions are met).

To accomplish this in Pipeline, we create our True/False conditions, then convert them to 1s and 0s using the as_factor() method, then add the 1s and 0s to create the score:

In [1]:
from zipline.pipeline import sharadar
from codeload.fundamental_factors.universe import CommonStocks, BaseUniverse

universe = BaseUniverse()

icr = sharadar.InterestCoverageRatio('ART', mask=universe)
altman = sharadar.AltmanZScore('ART', mask=universe)

distress_score = (icr < 1).as_factor() + (altman < 0).as_factor()

Let's create a Pipeline with our distress score, which we will analyze with Alphalens. We include size and volatility quantiles to be used as grouping factors:

In [2]:
from zipline.pipeline import Pipeline
from zipline.pipeline.factors import AnnualizedVolatility

fundamentals = sharadar.Fundamentals.slice("ART")
marketcap = fundamentals.MARKETCAP.latest

pipeline = Pipeline(
    columns={
        'distress_score': distress_score,
        'size': marketcap.quantiles(5, mask=universe),
        'volatility': AnnualizedVolatility(mask=universe).quantiles(5)
    },
    initial_universe=CommonStocks(),
    screen=universe
)

As in the previous notebook, we need to use the bins argument to define bin edges for Alphalens that correspond to our 3 possible scores. Since the possible scores are 0, 1, and 2, we can define the bin edges as [-1, 0, 1, 2]. When defining bins, each bin includes the rightmost edge but not the leftmost edge. In other words, our first bin, -1, 0, includes everything where $-1 < n <= 0$ (and thus includes 0), our second bin, 0, 1, includes everything where $0 < n <= 1$ (and thus includes 1), and so on.

In [3]:
import alphalens as al

al.from_pipeline(
    pipeline,
    start_date="1999-02-01",
    end_date="2022-12-30",
    periods=[1, 5, 21],
    factor="distress_score",
    bins=[-1, 0, 1, 2],
    groupby=[
        "size", 
        "volatility"
    ],
    segment="Y"
)
Factor Distribution
 minmaxmeanstdcountavg daily countcount %
Distress Score Quantile       
10.0000.0000.0000.00021,772,5243616.789.3%
21.0001.0001.0000.0002,548,361423.310.5%
32.0002.0002.0000.00057,1429.50.2%
Returns Analysis
 1D21D5D
Ann. alpha-0.017-0.030-0.025
beta0.0520.1970.123
Mean Relative Return Top Quantile (bps)1.1090.8311.771
Mean Relative Return Bottom Quantile (bps)0.0650.0460.060
Mean Spread (bps)1.044-1.085-0.105
Information Analysis
 1D21D5D
IC Mean-0.016-0.033-0.024
IC Std.0.0470.0660.057
Risk-Adjusted IC-0.343-0.502-0.419
t-stat(IC)-26.618-38.953-32.517
p-value(IC)0.0000.0000.000
IC Skew-0.0950.3920.072
IC Kurtosis1.0950.5410.827
/opt/conda/lib/python3.11/site-packages/scipy/stats/_stats_py.py:5445: ConstantInputWarning: An input array is constant; the correlation coefficient is not defined.
  warnings.warn(stats.ConstantInputWarning(warn_msg))
Turnover Analysis
 1D5D21D
Quantile 1 Mean Turnover0.0020.0120.040
Quantile 2 Mean Turnover0.0080.0380.145
Quantile 3 Mean Turnover0.0120.0560.210
1D21D5D
Mean Factor Rank Autocorrelation0.9960.9080.978
Out[3]:
1D21D5Dfactorfactor_quantilesizevolatility
dateasset
1999-02-01Equity(FIBBG000HRRSR1 [AAC1])0.013810-0.205392-0.0819820.0123
Equity(FIBBG000BGLR53 [AACE])0.035714-0.0982140.0000000.0122
Equity(FIBBG000M7KQ09 [AAI])0.0000000.1250000.2500000.01-13
Equity(FIBBG000H83MP4 [AAI1])0.035429-0.214286-0.1074291.0224
Equity(FIBBG000BD1373 [AAIC])-0.020376-0.0501570.1567400.0123
...........................
2022-12-30Equity(FIBBG011RWR2Q4 [ACRV])-0.0400000.350000-0.0333330.0113
Equity(FIBBG00ZSB4TS9 [DRS])-0.0093020.034884-0.0186050.01-13
Equity(FIBBG000BCVMH9 [CP])-0.0108740.0462800.0281130.0140
Equity(FIBBG000K5M1S8 [ENB])-0.0043290.0432900.0318310.0140
Equity(FIBBG000C32XT3 [IMO])0.0045340.128607-0.0043280.0141

24378027 rows × 7 columns

Tear sheet commentary¶

Factor Distribution table¶

  • min/max: Here, we can validate our bin edges. Factor quantile 1 contains score 0 (non-zombies, non-distressed); quantile 2 contains score 1 (zombies OR distressed); and quantile 3 contains score 2 (zombies AND distressed).
  • count %: Only 0.2% of stocks fall into quantile 3, indicating that it is very rare for a company to be both a zombie and distressed.

Returns Analysis¶

  • Relative Cumulative Return By Quantile: companies that are zombies and/or in distress perform worse than other companies. Quantile 3 (zombies AND distressed) performs similarly to quantile 2 (zombies OR distressed). In other words, being both a zombie and distressed does not result in worse returns than being only one or the other. Combined with the tiny size of quantile 3, this suggests that the best way to combine the interest coverage ratio and Altman Z-Score is to OR them, not AND them.

Back to Introduction