avatarSugath Mudali

Summary

The provided content outlines a method for calculating historical Altman Z-Scores using Python, with a focus on leveraging the yfinance API, Alpha Vantage, and Plotly for data retrieval, processing, and visualization.

Abstract

The article "How to calculate historical Altman Z-Score using Python" extends the discussion from a previous article on calculating the Altman Z-Score using the yfinance Python API. It introduces the use of Python libraries such as yfinance, pandas, and plotly to retrieve historical stock prices and financial reports, process the data, and visualize the historical Altman Z-Scores. The author provides detailed code snippets and explanations for reading quarterly reports, calculating the Z-Score components, and plotting the Z-Score over time. The Z-Score, a measure of a company's financial health, is calculated using specific financial ratios, and the article demonstrates how to interpret the results by categorizing the scores into distress, grey, and safe zones. The author also includes practical examples using real stock symbols (CFLT, IOT, BOX) to illustrate the application of the Z-Score analysis over a historical period, emphasizing the importance of considering the trend rather than a single time point to avoid bias from the most recent financial data.

Opinions

  • The author emphasizes the importance of historical financial analysis, suggesting that a single snapshot may not provide a comprehensive view of a company's financial health.
  • There is a clear endorsement of Python as a powerful tool for financial analysis, particularly highlighting the yfinance and plotly libraries for data retrieval and visualization.
  • The article conveys a preference for using multiple data sources, such as Alpha Vantage and Macrotrends, to overcome limitations in the number of quarterly financial reports available from a single API.
  • The author provides a disclaimer that the information is for educational purposes only and not intended as financial advice, indicating a responsible approach to discussing financial metrics.
  • By sharing code on GitHub and providing step-by-step instructions, the author demonstrates a commitment to open-source practices and community-driven learning in the field of financial analysis.

How to calculate historical Altman Z-Score using Python

This article explains how to calculate the historical Altman Z-Score with Python. This is an extension to the article “How to calculate Altman Z-Score using yfinance Python API”.

Disclaimer: The information provided here is for informational purposes only and is not intended to be personal financial, investment, or other advice.

I will not repeat the background information for the Altman Z-Score here because it was discussed in the previous article.

The yfinance API will be used to retrieve a stock’s historical close prices. It does not, however, provide an adequate number of quarterly financial reports (balance sheets and income statements). I’ll use Alpha Vantage to get over this limitation, although other options, such as Macrotrends, are viable alternatives. Since quarterly reports are only updated on a quarterly basis, I will use previously saved JSON files for offline access rather than making repetitive API calls to Alpha Vantage’s free limited access.

Python fundamentals and Plotly knowledge are assumed. Jupyter notebooks containing code for (a) saving financial data and (b) calculating historical Z-scores are available on GitHub. Please keep in mind that this article only covers part (b).

Python Libraries

The required Python libraries include:

  • yfinance: to access financial close prices; imported as
  • pandas : DataFrame and other utilities; imported as pd
  • plotly: for charting

Import Libraries

from datetime import datetime, timedelta
load a json file containing quarterly EPS
import json
# For DataFrame
import pandas as pd
# To access daily prices
import yfinance as yf
# import plotly for graph
import plotly.graph_objects as go
# For lower boundary, graph
import math
To access files
import os

Constants

# Symbol in focus
SYMBOL = 'CFLT'
# Where json files are stored
DATA_PATH = 'data'

# Name for BS and Income statement
BS = 'BALANCE_SHEET'
INCOME = 'INCOME_STATEMENT'

# Period in years
PERIOD = 2

# Using plotly dark template
TEMPLATE = 'plotly_dark'
  • SYMBOL — the stock symbol of interest
  • DATA_PATH — to access financial reports previously saved
  • BS, INCOME — short names for constant strings
  • PERIOD — how far back to start plotting from in years
  • TEMPLATE: template for plotly; replace with a valid plotly template name

Read Quarterly Reports

This is a helper method to read quarterly reports.

def read_qtrly_reports(report_type:str) -> list:
    # Specify the full path to load JSON data
    file_name = f'{os.path.join(DATA_PATH, SYMBOL)}_{report_type}.json'
    try:
        # Open the file in read mode
        with open(file_name, 'r') as file:
            # Use json.load() to parse the JSON data from the file
            return json.load(file)
    except FileNotFoundError:
        print(f"File '{file_name}' not found.")
    except json.JSONDecodeError as e:
        print(f"Error decoding JSON data: {e}")
    except Exception as e:
        print(f"An error occurred: {e}")

Use the above method to read the quarterly balance sheets:

bs_qtrly = read_qtrly_reports(BS)

# Dictionary to create BS DF
bs_dict = {
    'fiscalDateEnding':[],
    'totalAssets':[],
    'totalCurrentAssets':[],
    'totalCurrentLiabilities':[],
    'totalLiabilities':[],
    'retainedEarnings':[],
    'commonStockSharesOutstanding':[]
}
for item in bs_qtrly:
    for key in bs_dict.keys():
        if key == 'fiscalDateEnding':
            bs_dict[key].append(datetime.strptime(item[key], '%Y-%m-%d'))
        else:
            bs_dict[key].append(int(item[key]))
bs_df = pd.DataFrame.from_dict(bs_dict)
bs_df.head()

Only the seven elements required to calculate the Z-score are saved:

  • Total Assets (totalAssets)—required for X1, X2, X3 and X5, where X1 refers to working capital, total assets, etc. Please refer to my previous article for the rest.
  • Total Current Assets (totalCurrentAssets)—required to calculate working capital (X1)
  • Total Current Liabilities (totalCurrentLiabilities)—required to calculate working capital (X1)
  • Total Liabilities (totalLiabilities)—required for X4
  • Retained Earnings (retainedEarnings)—required for X2
  • Outstanding Common Shares (commonStockSharesOutstanding)—required to calculate Market value of equity (X4)
Top 5 rows of Balance Sheet DataFrame

Read the quarterly income statements:

income_qtrly = read_qtrly_reports(INCOME)

# Dictionary to create Income DF
income_dict = {
    'fiscalDateEnding':[],
    'totalRevenue':[],
    'ebit':[]
}
for item in income_qtrly:
    for key in income_dict.keys():
        if key == 'fiscalDateEnding':
            income_dict[key].append(datetime.strptime(item[key], '%Y-%m-%d'))
        else:
            income_dict[key].append(int(item[key]))
income_df = pd.DataFrame.from_dict(income_dict)
income_df.head()

Only the two elements required to calculate the Z-score are saved:

  • Total Revenue (totalRevenue)—required for X5
  • Earnings before interest (ebit)—required for X3
Top 5 rows of Income Statement DataFrame

Methods to calculate the Z-Score

# ratio_x_1: working capital / total assets
def ratio_x_1(fiscal_date:str) -> float:
    current_assets = get_report_attribute(BS, fiscal_date, 'totalCurrentAssets')
    current_liabilities = get_report_attribute(BS, fiscal_date, 'totalCurrentLiabilities')
    working_capital = current_assets - current_liabilities
    total_assets = get_report_attribute(BS, fiscal_date, 'totalAssets')
    if total_assets == 0: return 0
    return working_capital/total_assets

# retained earnings / total assets
def ratio_x_2(fiscal_date:str) -> float:
    retained_earnings = get_report_attribute(BS, fiscal_date, 'retainedEarnings')
    total_assets = get_report_attribute(BS, fiscal_date, 'totalAssets')
    if total_assets == 0: return 0
    return retained_earnings/total_assets

# earnings before interest and tax / total assets
def ratio_x_3(fiscal_date:str) -> float:
    ebit = get_report_attribute(INCOME, fiscal_date, 'ebit')
    total_assets = get_report_attribute(BS, fiscal_date, 'totalAssets')
    if total_assets == 0: return 0
    return ebit/total_assets

# market value of equity / total liabilities
def ratio_x_4(fiscal_date:str, price:float) -> float:
    equity_market_value = get_report_attribute(BS, fiscal_date, 'commonStockSharesOutstanding') * price
    total_liabilities = get_report_attribute(BS, fiscal_date, 'totalLiabilities')
    if total_liabilities == 0: return 0
    return equity_market_value/total_liabilities

# sales / total assets
def ratio_x_5(fiscal_date:str) -> float:
    sales = get_report_attribute(INCOME, fiscal_date, 'totalRevenue')
    total_assets = get_report_attribute(BS, fiscal_date, 'totalAssets')
    if total_assets == 0: return 0
    return sales/total_assets

All the above methods use the following helper method, which returns the value for the given financial report type, date, and element name:

def get_report_attribute(report_type:str, fiscal_date:str, name:str) -> int:
    df = bs_df if report_type == BS else income_df
    fiscal_df = df[df['fiscalDateEnding'] == fiscal_date]
    if len(fiscal_df) == 0: return 0
    return fiscal_df[name].iloc[0]

Calculate Z-Score

def z_score(fiscal_date:str, price:float) -> float:
    ratio_1 = ratio_x_1(fiscal_date)
    ratio_2 = ratio_x_2(fiscal_date)
    ratio_3 = ratio_x_3(fiscal_date)
    ratio_4 = ratio_x_4(fiscal_date, price)
    ratio_5 = ratio_x_5(fiscal_date)
    # Z = 1.2X1 + 1.4X2 + 3.3X3 + 0.6X4 + 1.0X5.
    zscore = 1.2*ratio_1 + 1.4*ratio_2 + 3.3*ratio_3 + 0.6*ratio_4 + 1.0*ratio_5
    return zscore

The next step is to calculate the Z-score for the symbol specified in the SYMBOL constant.

def create_zcore_df() -> pd.DataFrame:
    #1 Dates range for price query
    if len(bs_df) < len(income_df):
        price_dates = bs_df['fiscalDateEnding']
    else:
        price_dates = income_df['fiscalDateEnding']
    
    #2 Set the start date to x PERIOD before the current date
    start_date=(datetime.now() - timedelta(365 * PERIOD))
    #3 Adjust the start date based on the price dates range
    if price_dates.iloc[-1] > start_date:
        start_date = price_dates.iloc[-1]

    #4 Adjust the start date to string format
    start_date=start_date.strftime('%Y-%m-%d')
    
    # Set the end date to today's date
    end_date = datetime.now().strftime('%Y-%m-%d')
    
    #5 Get close prices
    ticker = yf.Ticker(SYMBOL)
    ticker_history = ticker.history(start=start_date, end=end_date)['Close']
    
    # Create a dictionary for the DF
    data_dict = {'date': [], 'z_score': []} 
    
    for index, row in ticker_history.items():
        close_date = index.replace(tzinfo=None)
        #6 Get the closest financial reporting date
        fiscal_date = [item for item in price_dates if close_date >= item][0]
        #7 Calculate Z score
        zscore = z_score(fiscal_date, ticker_history.loc[index])
        data_dict['date'].append(close_date)
        data_dict['z_score'].append(zscore)

    #8 Return DF
    return pd.DataFrame(data_dict)

Highlights:

  • #1—work out which date range for prices. If income DF is longer than balance sheet DF, then dates from balance sheet are the potential dates for the price
  • #2 — set the start time; n years ago is the default value (with respect to the current date)
  • #3 — ensure that the start date is within the price date range calculated at step #1
  • #4 — convert start and end dates to string formats for the yfinance call
  • #5 — get the price history using yfinance
  • #6 — get the closest financial statement date for a given date; for example, 30/06/2023 is the closest financial statement date for 20/07/2023. We only need the first element, as other financial dates such as 31/03/2023, 31/12/2022, etc., can be safely disregarded because they are not the closest dates. The list only contains financial statement dates that are below the given date, so financial statement dates newer than 20/08/2023, such as 30/09/2023, would not appear in the list.
  • #7 — calculate the Z-score for a day and store it in a dictionary
  • #8 — create a DF and return it
zscore_df = create_zcore_df()
zscore_df
Z-score values for CFLT symbol

Plot Z-Score

def plot_zscore(df:pd.DataFrame):    
    fig = go.Figure()
    
    #1 Plot the Close price
    fig.add_trace(go.Scatter(x=df.date, y=df.z_score, line_color='yellow', line_width=1, showlegend=False))

    #2 Plot the Distress zone
    y_point = df.z_score.min() if df.z_score.min() < 0 else 0
    fig.add_hrect(y0=y_point, y1=1.8, line_width=0, fillcolor='indianred', opacity=0.2,
                  annotation_text='Distress Zone', annotation_position='bottom right')

    #3 Plot the Grey zone
    fig.add_hrect(y0=1.81, y1=2.99, line_width=0, fillcolor='grey', opacity=0.2, annotation_text='Grey Zone',
                  annotation_position='bottom right')

    #4 Plot the Safe zone
    fig.add_hrect(y0=3.0, y1=df.z_score.max(), line_width=0, fillcolor='green', opacity=0.2,
                  annotation_text='Safe Zone', annotation_position='bottom right')

    #5 Maximum Z score
    max_row_id = df.z_score.idxmax()
    max_row = df.iloc[max_row_id]
    fig.add_trace(go.Scatter(x=[max_row.date], y=[max_row.z_score], mode='lines+markers+text', name='Max',
                             marker_line_color='green', marker_color='white', marker_line_width=2,
                             marker_size=10, text='Maximum', textposition='top center', textfont=dict(color='white')))

    #6 Minimum Z score
    min_row_id = df.z_score.idxmin()
    min_row = df.iloc[min_row_id]
    fig.add_trace(go.Scatter(x=[min_row.date], y=[min_row.z_score], mode='lines+markers+text', name='Min',
                             marker_line_color='indianred', marker_color='white', marker_line_width=2,
                             marker_size=10, text='Minimum', textposition='bottom center', textfont=dict(color='white')))

    #7 Update y  & x axis labels
    fig.update_yaxes(title_text='Z Score')
    fig.update_xaxes(title_text='Date')

    start_date = zscore_df.iloc[0]['date'].strftime('%Y-%m-%d')
    end_date = zscore_df.iloc[-1]['date'].strftime('%Y-%m-%d')
    layout = go.Layout(template=TEMPLATE, title=f'Altman Z Score for {SYMBOL} from {start_date} to {end_date}', height=700)
    
    #8 Update options and show plot
    fig.update_layout(layout)
    
    fig.show()

Highlights:

  • #1 — plot Close price
  • #2 — plot the Distress zone as a rectangle. Set the bottom y coordinate to 0 if the minimum z-score is non-negative. This is to ensure that the Distress rectangle is visible if the z-score stays above 1.8.
  • #3 — plot the Grey zone as a rectangle; it ranges from 1.81 to 2.99
  • #4 — plot the Safe zone as a rectangle; it ranges from 3.0 to the maximum z-score
  • #5, #6 — plot maximum and minimum z-scores
  • #7 — update the x and y axes
  • #8 — update layout and show the plot
Historical Z-scores for CFLT symbol

The Z-score for CFLT didn’t enter the Distress zone from March 17, 2022, until March 15, 2024. It did, however, spend extended periods of time in the Grey zone.

Let’s explore the IOT symbol, which appeared in the Safe zone in the previous article.

Historical Z-scores for IOT symbol

Symbol IOT never entered the Grey zone from May 4, 2022, until March 15, 2024.

Finally, the BOX symbol, which appeared in the Distress zone in the previous article, never managed to get into Grey zone from March 18, 2022, until March 12, 2024.

Historical Z-scores for BOX symbol

Conclusion

The historical Z-score trend can eliminate any bias from the most recent financial outcomes, as I mentioned in my earlier article.

I hope you found the information beneficial, and thank you for reading all the way through. I appreciate all your support. Till then, bye for now.

Further Reading:

  1. How to calculate Altman Z-Score using yfinance Python API
Python Programming
Z Score
Plotly
Yfinance
Stock Analysis
Recommended from ReadMedium