Rating Agency Methodologies¶
Rating agencies assign credit ratings that drive nearly every compliance test in a CLO indenture. CalcBridge must correctly interpret ratings from Moody's, S&P, and Fitch, map between their scales, and apply the right factor tables when computing WARF, diversity scores, and CCC bucket thresholds.
This page documents the rating scales, factor tables, and engineering implications for developers building or debugging CalcBridge's compliance calculation pipeline.
The Three Rating Scales¶
Each agency uses a different notation system. The underlying credit quality tiers are roughly equivalent, but the symbols are not interchangeable.
| Moody's | S&P | Fitch | Category |
|---|---|---|---|
| Aaa | AAA | AAA | Prime |
| Aa1 | AA+ | AA+ | High Grade |
| Aa2 | AA | AA | High Grade |
| Aa3 | AA- | AA- | High Grade |
| A1 | A+ | A+ | Upper Medium |
| A2 | A | A | Upper Medium |
| A3 | A- | A- | Upper Medium |
| Baa1 | BBB+ | BBB+ | Lower Medium |
| Baa2 | BBB | BBB | Lower Medium |
| Baa3 | BBB- | BBB- | Lower Medium |
| Ba1 | BB+ | BB+ | Speculative |
| Ba2 | BB | BB | Speculative |
| Ba3 | BB- | BB- | Speculative |
| B1 | B+ | B+ | Highly Speculative |
| B2 | B | B | Highly Speculative |
| B3 | B- | B- | Highly Speculative |
| Caa1 | CCC+ | CCC+ | Substantial Risk |
| Caa2 | CCC | CCC | Substantial Risk |
| Caa3 | CCC- | CCC- | Substantial Risk |
| Ca | CC | CC | Extremely Speculative |
| C | C/D | C/D/RD | Default |
Investment Grade Boundary
The boundary between investment grade and speculative grade sits between Baa3/BBB- and Ba1/BB+. This distinction matters for certain indenture tests that cap sub-investment-grade exposure.
Rating Factor Tables¶
Rating factors convert letter grades into numeric values for WARF (Weighted Average Rating Factor) calculations. Critically, Moody's and S&P use different factor scales. The same portfolio will produce different WARF numbers depending on which agency's factors are applied. This is not a bug -- it reflects genuinely different loss probability models.
| Credit Quality | Moody's Factor | S&P CDO Monitor Factor |
|---|---|---|
| Aaa / AAA | 1 | 0.52 |
| Aa2 / AA | 10 | 8 |
| A2 / A | 120 | 65 |
| Baa2 / BBB | 260 | 195 |
| Ba2 / BB | 1,350 | 1,106 |
| B2 / B | 2,720 | 2,556 |
| Caa1 / CCC+ | 4,770 | 4,108 |
Factor Table Granularity
The table above shows representative notch-level factors. Each sub-notch (e.g., B1 vs B2 vs B3) has its own distinct factor value. CalcBridge must store the complete factor table for every notch, not just the mid-tier values. See the full factor dictionaries in the code example below.
Intermediate Notch Factors (Moody's)¶
For engineers implementing the full lookup, here are the Moody's factors at each notch:
| Rating | Factor | Rating | Factor | Rating | Factor |
|---|---|---|---|---|---|
| Aaa | 1 | A1 | 70 | Ba1 | 940 |
| Aa1 | 10 | A2 | 120 | Ba2 | 1,350 |
| Aa2 | 10 | A3 | 180 | Ba3 | 1,766 |
| Aa3 | 20 | Baa1 | 260 | B1 | 2,220 |
| Baa2 | 260 | B2 | 2,720 | ||
| Baa3 | 610 | B3 | 3,490 | ||
| Caa1 | 4,770 | ||||
| Caa2 | 6,500 | ||||
| Caa3 | 8,070 |
WARF Calculation¶
WARF (Weighted Average Rating Factor) is the single most important rating-derived metric in CLO compliance. It measures the portfolio's overall credit quality as a par-weighted average of rating factors.
Worked Example¶
Consider a 3-loan portfolio:
| Loan | Par Value | Moody's Rating | Moody's Factor | S&P Rating | S&P Factor |
|---|---|---|---|---|---|
| Loan A | $50M | B1 | 2,220 | B+ | 2,040 |
| Loan B | $30M | Baa3 | 610 | BBB- | 437 |
| Loan C | $20M | Ba1 | 940 | BB+ | 776 |
Moody's WARF:
= ($50M * 2,220 + $30M * 610 + $20M * 940) / ($50M + $30M + $20M)
= (111,000,000 + 18,300,000 + 18,800,000) / 100,000,000
= 148,100,000 / 100,000,000
= 1,481 -- wait, let's recalculate with the actual numbers from the spec
Using the exact values specified:
Moody's WARF = (50 * 2,220 + 30 * 610 + 20 * 940) / 100
= (111,000 + 18,300 + 18,800) / 100
= 148,100 / 100
= 1,481
Different Factor Tables Produce Different Results
The same portfolio yields materially different WARF values depending on the agency:
- Moody's WARF: (50 * 2,220 + 30 * 610 + 20 * 940) / 100 = 1,481
- S&P WARF: (50 * 2,040 + 30 * 437 + 20 * 776) / 100 = 1,288.6
If the indenture WARF limit is 1,400, this portfolio fails Moody's but passes S&P. The rating_source parameter in CalcBridge determines which table is used, and it must match what the indenture specifies.
CalcBridge Implementation¶
import numpy as np
import pandas as pd
# Complete factor tables -- every notch must be present
MOODYS_FACTORS = {
"Aaa": 1, "Aa1": 10, "Aa2": 10, "Aa3": 20,
"A1": 70, "A2": 120, "A3": 180,
"Baa1": 260, "Baa2": 260, "Baa3": 610,
"Ba1": 940, "Ba2": 1350, "Ba3": 1766,
"B1": 2220, "B2": 2720, "B3": 3490,
"Caa1": 4770, "Caa2": 6500, "Caa3": 8070,
"Ca": 10000, "C": 10000,
}
SP_FACTORS = {
"AAA": 0.52, "AA+": 8, "AA": 8, "AA-": 15,
"A+": 35, "A": 65, "A-": 120,
"BBB+": 195, "BBB": 195, "BBB-": 437,
"BB+": 776, "BB": 1106, "BB-": 1543,
"B+": 2040, "B": 2556, "B-": 3214,
"CCC+": 4108, "CCC": 5520, "CCC-": 7362,
"CC": 10000, "C": 10000, "D": 10000,
}
FACTOR_TABLES = {
"moodys": MOODYS_FACTORS,
"sp": SP_FACTORS,
}
def calculate_warf(df: pd.DataFrame, rating_source: str = "moodys") -> float:
"""
Calculate Weighted Average Rating Factor using vectorized operations.
Args:
df: DataFrame with 'par_value' and rating columns.
rating_source: 'moodys' or 'sp' -- determines factor table and column.
Returns:
WARF as a float. Returns NaN if no valid ratings exist.
"""
factors = FACTOR_TABLES[rating_source]
rating_col = "rating_moodys" if rating_source == "moodys" else "rating_sp"
# Map ratings to factors -- unmapped ratings become NaN
df["_factor"] = df[rating_col].map(factors)
# Warn on unmapped ratings (data quality issue)
unmapped = df[df["_factor"].isna() & df[rating_col].notna()]
if len(unmapped) > 0:
unmapped_ratings = unmapped[rating_col].unique().tolist()
raise ValueError(
f"Unmapped {rating_source} ratings: {unmapped_ratings}. "
f"Update the factor table or fix the input data."
)
# Vectorized WARF: weighted average of factors by par value
valid = df.dropna(subset=["_factor", "par_value"])
if valid["par_value"].sum() == 0:
return float("nan")
warf = (valid["_factor"] * valid["par_value"]).sum() / valid["par_value"].sum()
# Clean up temporary column
df.drop(columns=["_factor"], inplace=True)
return float(warf)
Moody's Diversity Score¶
The diversity score is unique to Moody's and has no direct S&P or Fitch equivalent. It estimates the number of uncorrelated assets that would produce a similar loss distribution as the actual portfolio.
- A score of 80 means the portfolio behaves like 80 independent, equally-sized loans.
- Higher is better -- more diversification reduces tail risk.
- Many indentures specify a minimum diversity score alongside WARF limits.
How It Works¶
The calculation groups obligors by industry (using Moody's 33-industry classification), then applies pair-wise correlation adjustments. Two loans in the same industry contribute less to diversity than two loans in different industries.
flowchart LR
A[Portfolio<br/>Positions] --> B[Group by<br/>Obligor]
B --> C[Map to<br/>33 Industries]
C --> D[Calculate<br/>HHI per Industry]
D --> E[Apply<br/>Correlation Matrix]
E --> F[Diversity<br/>Score]
style F fill:#DCFCE7,stroke:#22C55E S&P and Fitch Alternatives
S&P uses the CDO Monitor model which internally accounts for concentration but does not output a single "diversity score" number. Fitch uses its own portfolio credit model. When an indenture references "diversity score" without qualification, it almost always means the Moody's methodology.
Industry Classification Differences¶
The same company can land in different industries depending on which agency's classification system the indenture specifies.
| Classification System | Number of Industries | Used By |
|---|---|---|
| Moody's 33-Industry | 33 categories | Moody's rated CLOs |
| S&P GICS | 11 sectors, 25 industry groups | S&P rated CLOs |
Example: A diversified financial services company might be classified as:
- Moody's: "Banking & Finance" (industry #1)
- S&P GICS: "Diversified Financial Services" (sector: Financials, industry group: 4020)
This matters because industry concentration tests produce different results depending on the classification system.
# Industry concentration depends on classification system
def calculate_industry_concentration(
df: pd.DataFrame,
classification: str = "moodys_33"
) -> pd.DataFrame:
"""
Calculate industry concentration using the specified classification.
Args:
df: DataFrame with 'par_value' and industry columns.
classification: 'moodys_33' or 'gics' -- determines grouping column.
Returns:
DataFrame with industry name, par exposure, and concentration percentage.
"""
industry_col = (
"industry_moodys" if classification == "moodys_33"
else "industry_gics_sector"
)
total_par = df["par_value"].sum()
concentration = (
df.groupby(industry_col)["par_value"]
.sum()
.reset_index()
.rename(columns={"par_value": "par_exposure"})
)
concentration["concentration_pct"] = (
concentration["par_exposure"] / total_par * 100
)
return concentration.sort_values("concentration_pct", ascending=False)
Template Configuration Required
When setting up a compliance test suite, the indenture specifies which industry classification to use. The CalcBridge template must store this as a parameter -- defaulting to Moody's 33-industry is incorrect for S&P-rated deals.
Cross-Mapping Between Scales¶
CalcBridge needs a cross-mapping service to convert ratings between agencies. This is required when:
- A loan has only one agency rating but the indenture requires another
- Split rating logic needs to compare across scales (see Split Ratings)
- Reporting requires a unified numeric scale
# Bidirectional cross-mapping
MOODYS_TO_SP = {
"Aaa": "AAA", "Aa1": "AA+", "Aa2": "AA", "Aa3": "AA-",
"A1": "A+", "A2": "A", "A3": "A-",
"Baa1": "BBB+", "Baa2": "BBB", "Baa3": "BBB-",
"Ba1": "BB+", "Ba2": "BB", "Ba3": "BB-",
"B1": "B+", "B2": "B", "B3": "B-",
"Caa1": "CCC+", "Caa2": "CCC", "Caa3": "CCC-",
"Ca": "CC", "C": "C",
}
SP_TO_MOODYS = {v: k for k, v in MOODYS_TO_SP.items()}
# Numeric scale for ordinal comparison (lower = better quality)
NUMERIC_SCALE = {
"Aaa": 1, "AAA": 1,
"Aa1": 2, "AA+": 2,
"Aa2": 3, "AA": 3,
"Aa3": 4, "AA-": 4,
"A1": 5, "A+": 5,
"A2": 6, "A": 6,
"A3": 7, "A-": 7,
"Baa1": 8, "BBB+": 8,
"Baa2": 9, "BBB": 9,
"Baa3": 10, "BBB-": 10,
"Ba1": 11, "BB+": 11,
"Ba2": 12, "BB": 12,
"Ba3": 13, "BB-": 13,
"B1": 14, "B+": 14,
"B2": 15, "B": 15,
"B3": 16, "B-": 16,
"Caa1": 17, "CCC+": 17,
"Caa2": 18, "CCC": 18,
"Caa3": 19, "CCC-": 19,
"Ca": 20, "CC": 20,
"C": 21, "D": 21,
}
def cross_map_rating(
rating: str,
from_agency: str,
to_agency: str
) -> str | None:
"""
Convert a rating from one agency's scale to another.
Returns None if the rating is not recognized.
"""
if from_agency == "moodys" and to_agency == "sp":
return MOODYS_TO_SP.get(rating)
elif from_agency == "sp" and to_agency == "moodys":
return SP_TO_MOODYS.get(rating)
return None
Engineering Implications¶
Implementing rating agency support correctly requires changes across several CalcBridge components:
1. Configurable Factor Tables¶
The WARF calculation service cannot hardcode a single factor table. It must accept a rating_source parameter that selects the appropriate table at runtime.
# In the compliance test suite configuration
{
"rating": {
"warf_rating_source": "moodys", # or "sp"
"warf_maximum": 2850,
"factor_table_version": "2024-01", # Factor tables can be updated
}
}
2. Template rating_source Parameter¶
Every compliance test template that touches ratings must carry a rating_source field. This affects:
- WARF calculation (factor table selection)
- CCC bucket definition (which rating to check)
- Diversity score (Moody's only)
- Industry concentration (classification system selection)
3. Cross-Mapping Service¶
A standalone service for converting between rating scales. Required for split rating resolution, reporting normalization, and cases where a loan has only one agency rating.
4. Industry Classification Support¶
The concentration test engine needs to support both Moody's 33-industry and GICS classification systems. The template must specify which system the indenture requires.
flowchart TD
A[Indenture<br/>Requirements] --> B{Which Agency?}
B -->|Moody's| C[Moody's Factors<br/>33-Industry<br/>Diversity Score]
B -->|S&P| D[S&P CDO Monitor<br/>GICS Sectors<br/>No Diversity Score]
B -->|Both| E[Split Rating<br/>Resolution Required]
C --> F[Configure Template]
D --> F
E --> F
F --> G[Run Compliance<br/>Tests]
style A fill:#EFF6FF,stroke:#3B82F6
style G fill:#DCFCE7,stroke:#22C55E See also:
- Test Suites & Thresholds -- configuring rating tests and split rating methods
- Loan Pricing and Compliance Impact -- how pricing affects OC tests and CCC haircuts
- Interpreting Results -- understanding WARF and rating test results