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 osConstants
# 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)

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

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_assetsAll 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 zscoreThe 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

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

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.

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.

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.





