Replicating Altman (1968): The Z-Score Bankruptcy Model¶
U.S.E. Finance Data Hub / WRDS Database: Compustat North America¶
Paper replicated: Altman, E. I. (1968). Financial Ratios, Discriminant Analysis and the Prediction of Corporate Bankruptcy. The Journal of Finance, 23(4), 589–609.
Database used: Compustat North America (Data Hub guide)
What this notebook does¶
Altman's Z-Score is one of the most influential and widely taught models in corporate finance. Using five accounting ratios combined via linear discriminant analysis, it produces a single score that classifies firms into safe, grey-zone, and distress categories based on bankruptcy risk.
The original 1968 study used a small, hand matched sample: 33 bankrupt and 33 non-bankrupt U.S. manufacturing firms (1946–1965), with data collected manually from financial statements. We can't reconstruct that exact matched sample from WRDS, as bankruptcy flagged firm lists aren't in Compustat, but we can demonstrate the full empirical machinery of the paper:
- Pull the underlying financial statement variables straight from Compustat North America via the WRDS API
- Construct all five Altman ratios exactly as defined in the paper
- Compute the Z-Score for a broad, modern cross-section of firms
- Classify firms into Altman's three risk zones and visualize the distribution
- Compare our distribution and coefficients against the published 1968 results, and discuss why (and how) a modern replication differs from the original
Learning objectives¶
- Practice connecting to WRDS and querying Compustat fundamentals data with SQL via the
wrdsPython library - Understand how a foundational empirical finance paper maps onto real Compustat fields
- Build intuition for financial ratio construction, data cleaning choices (e.g., excluding financial firms), and how sample construction choices affect results
Requirements to run this notebook¶
- A valid WRDS account with Compustat North America access (ask the Finance Data Hub if you don't have one yet)
pip install wrds pandas numpy matplotlib seaborn- You will be prompted for your WRDS username/password the first time you connect (Or set up a
.pgpassfile. See the WRDS Python guide)
1. Setup and WRDS connection¶
import wrds
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
sns.set_theme(style="whitegrid", palette="deep")
pd.set_option("display.max_columns", 50)
# Connecting to WRDS will prompt for your WRDS username and password
# (or read them from a stored .pgpass file if you've configured one).
db = wrds.Connection()
2. Identifying the Compustat variables we need¶
Altman's five ratios require the following raw Compustat fields, all available in the comp.funda table (Compustat North America > Fundamentals Annual):
| Altman component | Formula | Compustat field(s) |
|---|---|---|
| Working Capital | Current Assets − Current Liabilities | act, lct |
| Total Assets | — | at |
| Retained Earnings | — | re |
| EBIT | Earnings Before Interest and Taxes | ebit |
| Market Value of Equity | Share price × shares outstanding | prcc_f, csho |
| Total Liabilities | — | lt |
| Sales | Net Sales/Revenue | sale |
We also pull sich (historical SIC code), fyear (fiscal year), conm (company name), and gvkey (Compustat firm identifier) for filtering and labeling.
Sample restriction, following Altman's original design: Altman's model was built and validated on manufacturing firms. Financial firms (banks, insurers) have fundamentally different balance sheet structures (e.g., "current assets/liabilities" is not a meaningful concept for a bank), so we exclude SIC codes 6000–6999 just as the finance literature conventionally does when applying this model outside financials.
# Pull the most recent fiscal year of data (adjust the year range as you like).
# 'indfmt' = INDL (industrial format), 'datafmt' = STD, 'consol' = C, 'popsrc' = D
# are the standard filters WRDS recommends to avoid duplicate/restated records.
#
# NOTE: In comp.funda the SIC code column is called 'sich' (historical SIC),
# not 'sic'. The plain 'sic' column only exists in the comp.company header table.
query = """
SELECT gvkey, conm, fyear, datadate, sich,
at, lt, act, lct, re, ebit, sale,
prcc_f, csho
FROM comp.funda
WHERE indfmt = 'INDL'
AND datafmt = 'STD'
AND consol = 'C'
AND popsrc = 'D'
AND fyear BETWEEN 2019 AND 2023
"""
df = db.raw_sql(query)
print(f"Rows pulled: {len(df):,}")
df.head()
3. Cleaning the sample¶
# Drop rows missing any of the fields we need for the Z-Score calculation.
# We keep sich missing rows for now and handle them separately below,
# since some firms may simply lack a historical SIC code.
zscore_cols = ["at", "lt", "act", "lct", "re", "ebit", "sale", "prcc_f", "csho"]
df = df.dropna(subset=zscore_cols)
# Exclude financial firms (historical SIC 6000-6999)
# sich is numeric in Compustat; firms with missing sich are kept (conservative choice).
df = df[~((df["sich"] >= 6000) & (df["sich"] < 7000))]
# Exclude non-positive total assets (data errors) and take one observation per firm-year
df = df[df["at"] > 0]
df = df.drop_duplicates(subset=["gvkey", "fyear"])
print(f"Rows after cleaning: {len(df):,}")
print(f"Unique firms: {df['gvkey'].nunique():,}")
4. Constructing the Altman Z-Score¶
Altman's original (1968) discriminant function for publicly traded manufacturing firms:
$$Z = 1.2 X_1 + 1.4 X_2 + 3.3 X_3 + 0.6 X_4 + 1.0 X_5$$
Where:
- $X_1$ = Working Capital / Total Assets → liquidity relative to firm size
- $X_2$ = Retained Earnings / Total Assets → cumulative profitability / firm age & leverage history
- $X_3$ = EBIT / Total Assets → operating profitability, independent of tax/leverage effects
- $X_4$ = Market Value of Equity / Total Liabilities → market-based solvency (how far assets can fall before liabilities exceed value)
- $X_5$ = Sales / Total Assets → asset turnover / competitive positioning
Classification zones (from the paper):
- $Z > 2.99$ → "Safe" zone: low bankruptcy probability
- $1.81 \le Z \le 2.99$ → "Grey" zone: ambiguous
- $Z < 1.81$ → "Distress" zone: high bankruptcy probability
df["mve"] = df["prcc_f"] * df["csho"] # Market value of equity
df["X1"] = (df["act"] - df["lct"]) / df["at"]
df["X2"] = df["re"] / df["at"]
df["X3"] = df["ebit"] / df["at"]
df["X4"] = df["mve"] / df["lt"]
df["X5"] = df["sale"] / df["at"]
df["Z"] = 1.2*df["X1"] + 1.4*df["X2"] + 3.3*df["X3"] + 0.6*df["X4"] + 1.0*df["X5"]
def classify_zone(z):
if z > 2.99:
return "Safe"
elif z >= 1.81:
return "Grey"
else:
return "Distress"
df["zone"] = df["Z"].apply(classify_zone)
df[["conm", "fyear", "X1", "X2", "X3", "X4", "X5", "Z", "zone"]].sort_values("Z").head(10)
5. Visualizing the results¶
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
# Distribution of Z-scores, clipped for readability
z_clipped = df["Z"].clip(-5, 15)
axes[0].hist(z_clipped, bins=60, color="steelblue", edgecolor="white")
axes[0].axvline(1.81, color="red", linestyle="--", label="Distress cutoff (1.81)")
axes[0].axvline(2.99, color="green", linestyle="--", label="Safe cutoff (2.99)")
axes[0].set_title("Distribution of Altman Z-Scores\n(Compustat North America, 2019-2023)")
axes[0].set_xlabel("Z-Score")
axes[0].set_ylabel("Number of firm-years")
axes[0].legend()
# Zone counts
zone_counts = df["zone"].value_counts().reindex(["Distress", "Grey", "Safe"])
axes[1].bar(zone_counts.index, zone_counts.values,
color=["firebrick", "goldenrod", "seagreen"])
axes[1].set_title("Firm-Years by Altman Risk Zone")
axes[1].set_ylabel("Number of firm-years")
plt.tight_layout()
plt.show()
print(zone_counts)
print(f"\nShare in distress zone: {zone_counts['Distress'] / zone_counts.sum():.1%}")
# Trend over time: share of firms in each zone by fiscal year
zone_by_year = (
df.groupby(["fyear", "zone"]).size()
.unstack(fill_value=0)
.reindex(columns=["Distress", "Grey", "Safe"])
)
zone_share = zone_by_year.div(zone_by_year.sum(axis=1), axis=0)
zone_share.plot(kind="bar", stacked=True, figsize=(9, 5),
color=["firebrick", "goldenrod", "seagreen"])
plt.title("Share of Firms by Altman Zone, Over Time")
plt.ylabel("Share of firm-years")
plt.xlabel("Fiscal year")
plt.legend(title="Zone", bbox_to_anchor=(1.02, 1), loc="upper left")
plt.tight_layout()
plt.show()
6. Comparing to Altman (1968): What's the same, what's different¶
What replicates cleanly:
- The five ratios and their combination formula are exactly as specified in the original paper. This is the core empirical contribution and it maps directly onto standard Compustat fields.
- The classification cutoffs (1.81 / 2.99) are Altman's own published thresholds and are still the industry standard reference points taught today.
Where a modern WRDS-based replication necessarily differs from the original:
- Sample construction. Altman used a matched sample of exactly 33 bankrupt and 33 healthy manufacturing firms, selected specifically to validate a discriminant model. We instead pulled a broad, unmatched cross section of all manufacturing-adjacent firms in Compustat over a modern period, there is no bankruptcy flag field in Compustat, so we cannot reconstruct his exact treatment/control design without an external bankruptcy database (e.g., the WRDS/Wharton bankruptcy datasets or SEC filings).
- Model re-estimation vs. application. Altman's paper derives the coefficients (1.2, 1.4, 3.3, 0.6, 1.0) via linear discriminant analysis on his matched sample. We apply his already-published coefficients to new data rather than re-estimating them. Re-estimating would require the bankrupt/non-bankrupt labels mentioned above.
- Time period and economy. 1946 ~ 1965 U.S. manufacturing looked very different (capital intensity, leverage norms, accounting standards) from firms today, so the cross-sectional distribution of Z-scores will differ even if the formula is identical.
- Data source. Altman collected data by hand from financial statements; Compustat standardizes and restates historical data, which can shift ratios at the margin (e.g., restated
reoratvalues).
References¶
- Altman, E. I. (1968). Financial Ratios, Discriminant Analysis and the Prediction of Corporate Bankruptcy. The Journal of Finance, 23(4), 589–609.
- WRDS Compustat North America guide: https://uufinance.github.io/data/wrds/databases/compustat-north-america/
- WRDS Compustat North America access page: https://wrds-www.wharton.upenn.edu/pages/get-data/compustat-capital-iq-standard-poors/compustat/north-america-daily/
- WRDS Python/API setup guide: https://uufinance.github.io/data/wrds/notebook/
Prepared for the U.S.E. Finance Data Hub as a database-tutorial template.