Notebook

Replication on Morningstar Financial Health Grade

https://www.quantopian.com/help/fundamentals

Morningstar financial health grade is a convenient tool for users to incorporate distance to default to trading strategies. However, it is not easily accessible outside quantopian platform. Under this motivation, I tried to replicate the calculation of financial health grade.

The method that uses standard estimation on equity volatility gives 43% accuracy of grade A, and 67% accuracy of grade A and B. (on 2018-08-01 data)

Lately, it is interesting that even grade D and F can have a low probaility of default (<0.01). That may due to a narrower range in probaility of default while Morningstar ranks the distance to default instead of probability of default.

I did not include dividend in the calculation. You may try to include that and see the difference.

In [1]:
from quantopian.pipeline import Pipeline
from quantopian.research import run_pipeline
from quantopian.pipeline.factors import CustomFactor
from quantopian.pipeline.data import Fundamentals
from quantopian.pipeline.filters import QTradableStocksUS
from quantopian.pipeline.data.builtin import USEquityPricing
In [2]:
import numpy as np
import pandas as pd
import math
from scipy.stats import norm
from scipy.optimize import minimize
In [3]:
class DD_optimize (CustomFactor):
    
    inputs = [Fundamentals.market_cap, Fundamentals.total_liabilities_net_minority_interest, USEquityPricing.close]
    window_length = 252

    def compute(self, today, assets, out, tot_equity,tot_liabilities, close): #shape = M*N  M= days, N=stocks
        mertons = []
        
        for E, D, col_close in zip(tot_equity.T, tot_liabilities.T, close.T):#shape N*M and loop through each row
            T=1.             # maturity
            r_f=0.03          # risk-free
            
            def vol(col_close): #standard deviation on weighted average log return
                df = pd.DataFrame(col_close)
                df = df.pct_change()
                df = np.log(1 + df)
                return df.ewm(halflife=0.97).std().iloc[-1][0]
            
            sigma_e = vol(col_close)*math.sqrt(252)

            def equations(p):
                
                v_a, sigma_a = p

                d1 = (np.log(v_a/D[-1]) + (r_f + 0.5*sigma_a**2)*T)/(sigma_a*np.sqrt(T))
                d2 = d1 - sigma_a*np.sqrt(T)

                y1 = E[-1] - (v_a*norm.cdf(d1) - D[-1]*np.exp(-r_f*T)*norm.cdf(d2))

                y2 = sigma_e*E[-1] - norm.cdf(d1)*v_a*sigma_a

                return y1**2 + y2**2
            
            res = minimize(equations, [E[-1], sigma_e], method='nelder-mead') #nelder-mead method
            
            Asset_value = res.x[0]
            Asset_sigma = res.x[1]
            
                        
            numerator = np.log(Asset_value / D[-1]) + ( r_f - ((Asset_sigma**2) / 2))*T
            
            #norm.cdf(-mertons[0])
            
            mertons.append(numerator / (Asset_sigma*math.sqrt(T)))
                                    
        out[:] = mertons
In [4]:
class DD_optimize_2 (CustomFactor):
    
    inputs = [Fundamentals.market_cap, Fundamentals.total_liabilities_net_minority_interest, USEquityPricing.close]
    window_length = 252

    def compute(self, today, assets, out, tot_equity,tot_liabilities, close): #shape = M*N  M= days, N=stocks
         mertons = []
        
        for E, D, col_close in zip(tot_equity.T, tot_liabilities.T, close.T): #shape N*M and loop through each row
            T=1.             # maturity
            r_f=0.03          # risk-free
            
            def vol(col_close): #standard deviation on log return
                df = pd.DataFrame(col_close)
                df = df.pct_change()
                df = np.log(1 + df)
                return df.std()[0]
            
            sigma_e = vol(col_close)*math.sqrt(252) #annualized

            def equations(p):
                
                v_a, sigma_a = p

                d1 = (np.log(v_a/D[-1]) + (r_f + 0.5*sigma_a**2)*T)/(sigma_a*np.sqrt(T))
                d2 = d1 - sigma_a*np.sqrt(T)

                y1 = E[-1] - (v_a*norm.cdf(d1) - D[-1]*np.exp(-r_f*T)*norm.cdf(d2))

                y2 = sigma_e*E[-1] - norm.cdf(d1)*v_a*sigma_a

                return y1**2 + y2**2 #optimization function
            
            res = minimize(equations, [E[-1], sigma_e], method='nelder-mead') #nelder-mead method
            
            Asset_value = res.x[0]
            Asset_sigma = res.x[1]
            
                        
            numerator = np.log(Asset_value / D[-1]) + ( r_f - ((Asset_sigma**2) / 2))*T
            
            #norm.cdf(-mertons[0])
            
            mertons.append(numerator / (Asset_sigma*math.sqrt(T)))
                                    
        out[:] = mertons
In [5]:
pipe = Pipeline()

screen = QTradableStocksUS()

pipe.add(DD_optimize(mask=screen), 'DD_weightedAverage')
pipe.add(DD_optimize_2(mask=screen), 'DD_standardSD')
PD = Fundamentals.financial_health_grade.latest 
pipe.add(PD, 'PD')

pipe.set_screen(screen)
In [6]:
pipe.show_graph()
Out[6]:
<IPython.core.display.SVG object>
In [7]:
results = run_pipeline(pipe,'2018-8-1', '2018-8-1')
In [8]:
results.dropna(inplace=True)
In [9]:
results.head()
Out[9]:
DD_standardSD DD_weightedAverage PD
2018-08-01 00:00:00+00:00 Equity(2 [ARNC]) 3.244103 1.721075 C
Equity(24 [AAPL]) 9.232784 14.500249 A
Equity(41 [ARCB]) 3.328606 3.518391 C
Equity(52 [ABM]) 4.604355 11.658745 C
Equity(53 [ABMD]) 15.396421 9.217979 A
In [10]:
results['PD standardSD'] = norm.cdf(-results['DD_standardSD'])
In [11]:
results['PD weightedAverage'] = norm.cdf(-results['DD_weightedAverage'])
In [12]:
results.head()
Out[12]:
DD_standardSD DD_weightedAverage PD PD standardSD PD weightedAverage
2018-08-01 00:00:00+00:00 Equity(2 [ARNC]) 3.244103 1.721075 C 5.891062e-04 4.261863e-02
Equity(24 [AAPL]) 9.232784 14.500249 A 1.318440e-20 6.035544e-48
Equity(41 [ARCB]) 3.328606 3.518391 C 4.364087e-04 2.170858e-04
Equity(52 [ABM]) 4.604355 11.658745 C 2.068732e-06 1.035365e-31
Equity(53 [ABMD]) 15.396421 9.217979 A 8.649049e-54 1.513763e-20

A grade

Filter by 0.9 quantile and groupby A to F grade.

Check how many percent of stocks are classified similar to morningstar method

In [13]:
count_WA = results[results['DD_weightedAverage'] >= results['DD_weightedAverage'].quantile(0.9)].groupby('PD').count()
count_SD = results[results['DD_standardSD'] >= results['DD_standardSD'].quantile(0.9)].groupby('PD').count()
/usr/local/lib/python2.7/dist-packages/pandas/core/groupby.py:2193: FutureWarning: 
Setting NaNs in `categories` is deprecated and will be removed in a future version of pandas.
  ordered=self.grouper.ordered))
/usr/local/lib/python2.7/dist-packages/pandas/indexes/category.py:121: FutureWarning: 
Setting NaNs in `categories` is deprecated and will be removed in a future version of pandas.
  data = data.set_categories(categories)
In [14]:
count_PD = results.groupby('PD').count()
In [15]:
count_WA.div(count_PD)['DD_weightedAverage']
Out[15]:
PD
A      0.215584
C      0.040586
B      0.139013
D      0.011299
F      0.000000
NaN         NaN
Name: DD_weightedAverage, dtype: float64
In [16]:
count_SD.div(count_PD)['DD_standardSD']
Out[16]:
PD
A      0.428571
C      0.003382
B      0.068759
D      0.000000
F      0.000000
NaN         NaN
Name: DD_standardSD, dtype: float64

A, and B grade

Filter by 0.7 quantile and groupby A to F grade.

Check how many percent of stocks are classified similar to morningstar method

In [17]:
count_WA = results[results['DD_weightedAverage'] >= results['DD_weightedAverage'].quantile(0.7)].groupby('PD').count()
count_SD = results[results['DD_standardSD'] >= results['DD_standardSD'].quantile(0.7)].groupby('PD').count()
In [18]:
count_WA.div(count_PD)['DD_weightedAverage']
Out[18]:
PD
A      0.540260
C      0.188275
B      0.382661
D      0.062147
F      0.000000
NaN         NaN
Name: DD_weightedAverage, dtype: float64
In [19]:
count_SD.div(count_PD)['DD_standardSD']
Out[19]:
PD
A      0.976623
C      0.015784
B      0.375187
D      0.005650
F      0.000000
NaN         NaN
Name: DD_standardSD, dtype: float64

Probability of Default

In [20]:
PD_WA = results[results['PD weightedAverage'] <=0.01]
In [21]:
PD_WA.groupby('PD').count().div(count_PD)['PD weightedAverage']
Out[21]:
PD
A      0.979221
C      0.802706
B      0.971599
D      0.548023
F      0.650000
NaN         NaN
Name: PD weightedAverage, dtype: float64
In [22]:
PD_SD = results[results['PD standardSD'] <=0.01]
In [23]:
PD_SD.groupby('PD').count().div(count_PD)['PD standardSD']
Out[23]:
PD
A      1.000000
C      0.945885
B      0.998505
D      0.491525
F      0.600000
NaN         NaN
Name: PD standardSD, dtype: float64